diff --git a/Cargo.lock b/Cargo.lock index aa81add4..895a6ef3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "Inflector" @@ -39,9 +39,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -63,18 +63,18 @@ dependencies = [ [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arbitrary" @@ -106,6 +106,18 @@ dependencies = [ "x11rb", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash 0.5.0", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -199,7 +211,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -228,13 +240,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -336,6 +348,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -374,13 +392,22 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -401,9 +428,9 @@ dependencies = [ [[package]] name = "blocking" -version = "1.6.2" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" dependencies = [ "async-channel", "async-task", @@ -450,9 +477,9 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "byteorder" @@ -468,9 +495,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] @@ -501,7 +528,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "cairo-sys-rs", "glib", "libc", @@ -522,9 +549,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.3" +version = "1.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ce8d3bd5823c7504d3f579f13e7b2f3da252fcb938c594d5680ee508bf846f" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" dependencies = [ "serde_core", ] @@ -549,7 +576,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -564,9 +591,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.65" +version = "1.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" dependencies = [ "find-msvc-tools", "jobserver", @@ -603,18 +630,24 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -653,12 +686,43 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "citadel-opds" +version = "0.1.0" +dependencies = [ + "argon2", + "axum", + "base64 0.22.1", + "bytes", + "chrono", + "diesel", + "futures-util", + "hmac", + "libcalibre", + "netdev", + "quick-xml 0.38.4", + "rand_core 0.6.4", + "reqwest 0.12.28", + "serde", + "serde_json", + "sha2", + "specta", + "subtle", + "tempfile", + "tokio", + "tower", + "urlencoding", + "uuid", +] + [[package]] name = "citadel-rs" version = "0.6.1" dependencies = [ + "axum", "chrono", "citadel-core", + "citadel-opds", "diesel", "epub", "image", @@ -686,11 +750,27 @@ dependencies = [ "tauri-plugin-webdriver-automation", "tauri-specta", "tempfile", + "tokio", "urlencoding", - "uuid", "zip 0.6.6", ] +[[package]] +name = "citadel-server" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "chrono", + "citadel-opds", + "libcalibre", + "nix", + "reqwest 0.12.28", + "serde", + "tempfile", + "tokio", + "toml 0.9.12+spec-1.1.0", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -702,9 +782,9 @@ dependencies = [ [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", @@ -721,9 +801,9 @@ dependencies = [ [[package]] name = "console" -version = "0.16.3" +version = "0.16.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3" dependencies = [ "encode_unicode", "libc", @@ -738,9 +818,9 @@ checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" [[package]] name = "cookie" -version = "0.18.1" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" dependencies = [ "time", "version_check", @@ -778,7 +858,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "core-foundation 0.10.1", "core-graphics-types", "foreign-types 0.5.0", @@ -791,7 +871,7 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "core-foundation 0.10.1", "core-graphics-types", "foreign-types 0.5.0", @@ -804,11 +884,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "core-foundation 0.10.1", "libc", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -820,36 +906,36 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" dependencies = [ "cfg-if", ] [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crunchy" @@ -887,7 +973,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -918,12 +1004,12 @@ dependencies = [ [[package]] name = "darling" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", + "darling_core 0.24.1", + "darling_macro 0.24.1", ] [[package]] @@ -937,20 +1023,20 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "darling_core" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" dependencies = [ "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -961,31 +1047,62 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "darling_macro" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ - "darling_core 0.23.0", + "darling_core 0.24.1", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] name = "dbus" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" dependencies = [ "libc", "libdbus-sys", "windows-sys 0.61.2", ] +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1003,7 +1120,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1024,14 +1141,14 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "diesel" -version = "2.3.10" +version = "2.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29fe29a87fb84c631ffb3ba21798c4b1f3a964701ba78f0dce4bf8668562ec88" +checksum = "e3b934ddbdcb2abb9f9fc9c30bd47bcc5618b615eea1d334cda5fdf8ff9b072a" dependencies = [ "chrono", "diesel_derives", @@ -1044,15 +1161,15 @@ dependencies = [ [[package]] name = "diesel_derives" -version = "2.3.9" +version = "2.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1817b7f4279b947fc4cafddec12b0e5f8727141706561ce3ac94a60bddd1cf5" +checksum = "ecbd51fb6c020672543641167efa4e6417ff7ad76849ed556ace3595e72de03a" dependencies = [ "diesel_table_macro_syntax", "dsl_auto_type", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1072,7 +1189,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" dependencies = [ - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1113,7 +1230,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "block2", "libc", "objc2", @@ -1121,13 +1238,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -1150,7 +1267,7 @@ checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1206,7 +1323,7 @@ dependencies = [ "objc2-foundation", "raw-window-handle", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", "windows 0.52.0", "windows-core 0.58.0", ] @@ -1222,7 +1339,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1269,20 +1386,20 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "either" -version = "1.16.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "embed-resource" -version = "3.0.9" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" dependencies = [ "cc", "memchr", "rustc_version", - "toml 1.1.2+spec-1.1.0", + "toml 1.1.6+spec-1.1.0", "vswhom", "winreg", ] @@ -1365,11 +1482,17 @@ checksum = "a246d82be1c9d791c5dfde9a2bd045fc3cbba3fa2b11ad558f27d01712f00569" [[package]] name = "encoding_rs" -version = "0.8.35" +version = "0.8.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" dependencies = [ "cfg-if", + "core_detect", + "multiversion", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", ] [[package]] @@ -1396,7 +1519,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1407,7 +1530,7 @@ checksum = "95518004c0a638e03a17589d2d336b7c936d92184d81bf1e66d3b1555de89f2d" dependencies = [ "percent-encoding", "regex", - "thiserror 2.0.18", + "thiserror 2.0.20", "xml-rs", "zip 3.0.0", ] @@ -1441,17 +1564,16 @@ dependencies = [ [[package]] name = "error-code" -version = "3.3.2" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -1480,9 +1602,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "fax" @@ -1521,9 +1643,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" [[package]] name = "fixedbitset" @@ -1533,12 +1655,12 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", "zlib-rs", ] @@ -1581,13 +1703,13 @@ dependencies = [ [[package]] name = "foreign-types-macros" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -1613,24 +1735,25 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", + "futures-sink", ] [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -1639,9 +1762,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -1658,32 +1781,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-io", @@ -1887,7 +2010,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "futures-channel", "futures-core", "futures-executor", @@ -1915,7 +2038,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1930,9 +2053,9 @@ dependencies = [ [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "gobject-sys" @@ -1994,14 +2117,14 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "h2" -version = "0.4.15" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", @@ -2009,7 +2132,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.14.0", + "indexmap 2.14.2", "slab", "tokio", "tokio-util", @@ -2089,9 +2212,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" [[package]] name = "hex" @@ -2120,9 +2243,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -2130,9 +2253,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -2140,9 +2263,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -2171,9 +2294,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.10.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", @@ -2283,9 +2406,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -2297,9 +2420,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -2310,9 +2433,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -2324,16 +2447,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -2344,15 +2468,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -2419,9 +2543,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -2462,9 +2586,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "is-docker" @@ -2514,6 +2638,60 @@ dependencies = [ "system-deps", ] +[[package]] +name = "jiff" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.21.1" @@ -2542,7 +2720,7 @@ dependencies = [ "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror 2.0.18", + "thiserror 2.0.20", "walkdir", "windows-link 0.2.1", ] @@ -2557,7 +2735,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2585,24 +2763,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.102" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ "cfg-if", "futures-util", @@ -2637,7 +2815,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "serde", "unicode-segmentation", ] @@ -2668,9 +2846,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libcalibre" @@ -2717,9 +2895,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a" dependencies = [ "libc", ] @@ -2742,9 +2920,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "lock_api" @@ -2757,9 +2935,15 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "mac-addr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" [[package]] name = "markup5ever" @@ -2780,9 +2964,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memoffset" @@ -2836,11 +3020,21 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" -version = "1.2.1" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "wasi", @@ -2885,10 +3079,37 @@ dependencies = [ "once_cell", "png 0.18.1", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", "windows-sys 0.61.2", ] +[[package]] +name = "multiversion" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" +dependencies = [ + "multiversion-macros", +] + +[[package]] +name = "multiversion-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" +dependencies = [ + "proc-macro2", + "quote", + "rustversion", + "syn 3.0.6", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "native-tls" version = "0.2.18" @@ -2912,7 +3133,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "jni-sys 0.3.1", "log", "ndk-sys", @@ -2930,12 +3151,81 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "netdev" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "569dfbdd2efd771b24ec9bb57f956e04d4fbfc72f62b2f11961723f9b3f4b020" +dependencies = [ + "block2", + "dispatch2", + "dlopen2", + "ipnet", + "libc", + "mac-addr", + "netlink-packet-core", + "netlink-packet-route", + "netlink-sys", + "objc2", + "objc2-core-foundation", + "objc2-core-wlan", + "objc2-foundation", + "objc2-system-configuration", + "once_cell", + "plist", + "windows-sys 0.61.2", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b897d7bd4f0af82e68d40d0344cf37e97f9c97ddf74a098de3e4da05e96ca395" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" +dependencies = [ + "bitflags 2.13.2", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "libc", + "log", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "8.0.0" @@ -2979,7 +3269,7 @@ dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2998,7 +3288,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "block2", "libc", "objc2", @@ -3019,7 +3309,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "objc2", "objc2-foundation", ] @@ -3030,7 +3320,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "objc2", "objc2-foundation", ] @@ -3041,8 +3331,10 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", + "block2", "dispatch2", + "libc", "objc2", ] @@ -3052,7 +3344,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "dispatch2", "objc2", "objc2-core-foundation", @@ -3085,7 +3377,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "objc2", "objc2-core-foundation", "objc2-core-graphics", @@ -3097,13 +3389,27 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "objc2", "objc2-core-foundation", "objc2-core-graphics", "objc2-io-surface", ] +[[package]] +name = "objc2-core-wlan" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-security", + "objc2-security-foundation", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -3125,7 +3431,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "block2", "libc", "objc2", @@ -3138,7 +3444,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "objc2", "objc2-core-foundation", ] @@ -3149,7 +3455,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "objc2", "objc2-app-kit", "objc2-foundation", @@ -3161,19 +3467,48 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "objc2", "objc2-core-foundation", "objc2-foundation", ] +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-security-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef76382e9cedd18123099f17638715cc3d81dba3637d4c0d39ab69df2ef345a5" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + [[package]] name = "objc2-ui-kit" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "block2", "objc2", "objc2-cloud-kit", @@ -3204,7 +3539,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "block2", "objc2", "objc2-app-kit", @@ -3220,14 +3555,13 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "open" -version = "5.3.5" +version = "5.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" dependencies = [ "dunce", "is-wsl", "libc", - "pathdiff", ] [[package]] @@ -3236,7 +3570,7 @@ version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "cfg-if", "foreign-types 0.3.2", "libc", @@ -3252,7 +3586,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3310,7 +3644,7 @@ dependencies = [ "objc2-osa-kit", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -3379,16 +3713,21 @@ dependencies = [ ] [[package]] -name = "paste" -version = "1.0.15" +name = "password-hash" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] [[package]] -name = "pathdiff" -version = "0.2.3" +name = "paste" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pbkdf2" @@ -3398,7 +3737,7 @@ checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" dependencies = [ "digest", "hmac", - "password-hash", + "password-hash 0.4.2", "sha2", ] @@ -3416,7 +3755,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ "fixedbitset", "hashbrown 0.15.5", - "indexmap 2.14.0", + "indexmap 2.14.2", ] [[package]] @@ -3460,7 +3799,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3491,19 +3830,19 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "plist" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb" dependencies = [ - "base64 0.22.1", - "indexmap 2.14.0", - "quick-xml 0.39.4", + "base64 0.23.1", + "indexmap 2.14.2", + "quick-xml 0.42.0", "serde", "time", ] @@ -3518,7 +3857,7 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -3527,11 +3866,11 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -3548,11 +3887,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -3604,7 +3958,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.12+spec-1.1.0", + "toml_edit 0.25.15+spec-1.1.0", ] [[package]] @@ -3633,9 +3987,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -3648,9 +4002,9 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", "bit-vec", - "bitflags 2.13.0", + "bitflags 2.13.2", "num-traits", - "rand 0.9.4", + "rand 0.9.5", "rand_chacha", "rand_xorshift", "regex-syntax", @@ -3661,9 +4015,9 @@ dependencies = [ [[package]] name = "pxfm" -version = "0.1.29" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" [[package]] name = "quick-error" @@ -3688,18 +4042,27 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.39.4" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" dependencies = [ "memchr", ] [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -3729,9 +4092,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", "rand_core 0.9.5", @@ -3739,9 +4102,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -3763,6 +4126,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] [[package]] name = "rand_core" @@ -3800,45 +4166,44 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", ] [[package]] name = "redox_users" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" dependencies = [ - "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "ref-cast" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] name = "regex" -version = "1.12.4" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -3848,9 +4213,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -3872,7 +4237,9 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", + "futures-channel", "futures-core", + "futures-util", "h2", "http", "http-body", @@ -3905,11 +4272,11 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "futures-core", "futures-util", @@ -3987,7 +4354,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -3996,7 +4363,7 @@ version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -4006,9 +4373,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -4021,11 +4388,11 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", @@ -4034,9 +4401,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "once_cell", "ring", @@ -4060,9 +4427,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "zeroize", ] @@ -4096,9 +4463,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", @@ -4107,9 +4474,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "rusty-fork" @@ -4191,9 +4558,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "dyn-clone", "ref-cast", @@ -4210,7 +4577,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4225,7 +4592,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -4248,7 +4615,7 @@ version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "cssparser", "derive_more", "log", @@ -4273,9 +4640,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -4295,22 +4662,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -4321,14 +4688,14 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -4350,13 +4717,13 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -4391,18 +4758,19 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.21.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bs58", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.14.0", + "indexmap 2.14.2", + "jiff", "schemars 0.9.0", - "schemars 1.2.1", + "schemars 1.2.2", "serde_core", "serde_json", "serde_with_macros", @@ -4411,14 +4779,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.21.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" dependencies = [ - "darling 0.23.0", + "darling 0.24.1", "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -4427,7 +4795,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "itoa", "ryu", "serde", @@ -4453,7 +4821,7 @@ checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4467,9 +4835,9 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -4489,13 +4857,13 @@ dependencies = [ [[package]] name = "shared_child" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7" +checksum = "607549934f6cc26b89cfecfdc46fa90f1e5d1536a68349b0c3a4f9d1c0d37959" dependencies = [ "libc", "sigchld", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4506,9 +4874,9 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "sigchld" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1" +checksum = "24f2b37f04360cd465089b87a9c3869c08220a2f3458463f0adf8badf5e77f2c" dependencies = [ "libc", "os_pipe", @@ -4517,9 +4885,9 @@ dependencies = [ [[package]] name = "signal-hook" -version = "0.3.18" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" dependencies = [ "libc", "signal-hook-registry", @@ -4537,15 +4905,15 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version", "simdutf8", @@ -4577,15 +4945,15 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -4660,7 +5028,7 @@ dependencies = [ "Inflector", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4740,9 +5108,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "swift-rs" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +checksum = "e45c444e496845d3f2a351146bff59aae4975b2280238df1dfaa0c7d1846f38e" dependencies = [ "base64 0.21.7", "serde", @@ -4761,9 +5129,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -4781,13 +5160,13 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -4796,7 +5175,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -4830,7 +5209,7 @@ version = "0.35.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "block2", "core-foundation 0.10.1", "core-graphics 0.25.0", @@ -4866,13 +5245,13 @@ dependencies = [ [[package]] name = "tao-macros" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4894,9 +5273,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.11.3" +version = "2.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2616f96cb644bf2c5c456d9de4d5d5100e592d7424c74d8b55c5cb96e359e93" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" dependencies = [ "anyhow", "bytes", @@ -4923,7 +5302,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest 0.13.4", + "reqwest 0.13.5", "serde", "serde_json", "serde_repr", @@ -4935,7 +5314,7 @@ dependencies = [ "tauri-runtime", "tauri-runtime-wry", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tray-icon", "url", @@ -4984,9 +5363,9 @@ dependencies = [ "serde", "serde_json", "sha2", - "syn 2.0.118", + "syn 2.0.119", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "url", "uuid", @@ -5002,7 +5381,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "tauri-codegen", "tauri-utils", ] @@ -5025,9 +5404,9 @@ dependencies = [ [[package]] name = "tauri-plugin-clipboard-manager" -version = "2.3.2" +version = "2.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "206dc20af4ed210748ba945c2774e60fd0acd52b9a73a028402caf809e9b6ecf" +checksum = "4136fb69d967753d000423d7e5f863f89bf949efbdfbecb43a580426a01a0194" dependencies = [ "arboard", "log", @@ -5035,14 +5414,14 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "tauri-plugin-dialog" -version = "2.7.1" +version = "2.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +checksum = "61854a36651aa48381e5e209f69a01273b77f3f9f91f0c430b1b98d33bd47229" dependencies = [ "log", "raw-window-handle", @@ -5052,7 +5431,7 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-plugin-fs", - "thiserror 2.0.18", + "thiserror 2.0.20", "url", ] @@ -5068,14 +5447,14 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "tauri-plugin-fs" -version = "2.5.1" +version = "2.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +checksum = "de22eef34fd78c0da050e748710edd50bf127e651d02ea1b2bfada1523cc5c51" dependencies = [ "anyhow", "dunce", @@ -5090,16 +5469,16 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-utils", - "thiserror 2.0.18", - "toml 1.1.2+spec-1.1.0", + "thiserror 2.0.20", + "toml 1.1.6+spec-1.1.0", "url", ] [[package]] name = "tauri-plugin-opener" -version = "2.5.4" +version = "2.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" +checksum = "60d60366174b745b4ef5824b8bbc1c457fd08f0ce101ff643c0a49181a9f4e91" dependencies = [ "dunce", "glob", @@ -5111,7 +5490,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.18", + "thiserror 2.0.20", "url", "windows 0.61.3", "zbus", @@ -5119,9 +5498,9 @@ dependencies = [ [[package]] name = "tauri-plugin-persisted-scope" -version = "2.3.7" +version = "2.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b560a5962bf975d38fb4ec98a0e64e52929992ec708acb812add9c1ab8d186d" +checksum = "3455c9b9d86158aa9acf596b2385fd99f3963e16fce0f5a613020b4388a36770" dependencies = [ "aho-corasick", "bincode", @@ -5130,14 +5509,14 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin-fs", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "tauri-plugin-shell" -version = "2.3.5" +version = "2.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b" +checksum = "8548af174c5516e4f71f142acea4d02e00316296ea9aafa58798851481003e3c" dependencies = [ "encoding_rs", "log", @@ -5150,31 +5529,31 @@ dependencies = [ "shared_child", "tauri", "tauri-plugin", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", ] [[package]] name = "tauri-plugin-store" -version = "2.4.3" +version = "2.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c72dda16786eb4a3f903e43a17b64d8d78dc0f00fe2aa4b757c28f617a8630b" +checksum = "f2c7c771d3ce796b48c6e52b2fac38087d479a3c58e0424d3850cc21db6b4a69" dependencies = [ "dunce", "serde", "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tracing", ] [[package]] name = "tauri-plugin-updater" -version = "2.10.1" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" +checksum = "b28d8cabdeb0564f03ae261963de4bc3d98321cd3d213e76a81b7d344e5df606" dependencies = [ "base64 0.22.1", "dirs", @@ -5186,7 +5565,7 @@ dependencies = [ "minisign-verify", "osakit", "percent-encoding", - "reqwest 0.13.4", + "reqwest 0.13.5", "rustls", "semver", "serde", @@ -5195,7 +5574,7 @@ dependencies = [ "tauri", "tauri-plugin", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "tokio", "url", @@ -5237,7 +5616,7 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.20", "url", "webkit2gtk", "webview2-com", @@ -5246,9 +5625,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.11.3" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe41e015bf8fc4d6477ff4926a0ef769dc64ff34c7b0038b6f7cacae892acb5c" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", @@ -5283,7 +5662,7 @@ dependencies = [ "specta-typescript", "tauri", "tauri-specta-macros", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -5295,7 +5674,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5328,8 +5707,8 @@ dependencies = [ "serde_json", "serde_with", "swift-rs", - "thiserror 2.0.18", - "toml 1.1.2+spec-1.1.0", + "thiserror 2.0.20", + "toml 1.1.6+spec-1.1.0", "url", "urlpattern", "uuid", @@ -5344,7 +5723,7 @@ checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" dependencies = [ "dunce", "embed-resource", - "toml 1.1.2+spec-1.1.0", + "toml 1.1.6+spec-1.1.0", ] [[package]] @@ -5362,12 +5741,11 @@ dependencies = [ [[package]] name = "tendril" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" dependencies = [ "new_debug_unreachable", - "utf-8", ] [[package]] @@ -5381,11 +5759,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.20", ] [[package]] @@ -5396,18 +5774,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -5426,9 +5804,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.51" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "num-conv", @@ -5446,9 +5824,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.30" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -5456,9 +5834,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -5466,24 +5844,15 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -5498,13 +5867,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -5519,9 +5888,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" dependencies = [ "rustls", "tokio", @@ -5529,13 +5898,14 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -5558,7 +5928,7 @@ version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "serde_core", "serde_spanned 1.1.1", "toml_datetime 0.7.5+spec-1.1.0", @@ -5569,17 +5939,17 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "1.1.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "serde_core", "serde_spanned 1.1.1", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -5615,7 +5985,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "toml_datetime 0.6.3", "winnow 0.5.40", ] @@ -5626,7 +5996,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.3", @@ -5635,30 +6005,30 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.15+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tower" @@ -5682,7 +6052,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "bytes", "futures-util", "http", @@ -5726,7 +6096,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5740,9 +6110,9 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.24.1" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" dependencies = [ "crossbeam-channel", "dirs", @@ -5756,7 +6126,7 @@ dependencies = [ "once_cell", "png 0.18.1", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", "windows-sys 0.61.2", ] @@ -5849,9 +6219,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "unicode-segmentation" @@ -5902,12 +6272,6 @@ dependencies = [ "url", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -5916,13 +6280,13 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.23.3" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "getrandom 0.4.3", "js-sys", - "rand 0.10.1", + "rand 0.10.2", "serde_core", "wasm-bindgen", ] @@ -6010,9 +6374,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.125" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", @@ -6023,9 +6387,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.75" +version = "0.4.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" dependencies = [ "js-sys", "wasm-bindgen", @@ -6033,9 +6397,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.125" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6043,22 +6407,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.125" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.125" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] @@ -6078,9 +6442,9 @@ dependencies = [ [[package]] name = "wayland-backend" -version = "0.3.15" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs 1.2.1", @@ -6091,11 +6455,11 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.14" +version = "0.31.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "rustix", "wayland-backend", "wayland-scanner", @@ -6107,7 +6471,7 @@ version = "0.32.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "wayland-backend", "wayland-client", "wayland-scanner", @@ -6119,7 +6483,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "wayland-backend", "wayland-client", "wayland-protocols", @@ -6128,12 +6492,12 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.10" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml 0.39.4", + "quick-xml 0.41.0", "quote", ] @@ -6148,9 +6512,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.102" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" dependencies = [ "js-sys", "wasm-bindgen", @@ -6158,9 +6522,9 @@ dependencies = [ [[package]] name = "web_atoms" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" dependencies = [ "phf", "phf_codegen", @@ -6214,9 +6578,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ "rustls-pki-types", ] @@ -6243,7 +6607,7 @@ checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -6252,7 +6616,7 @@ version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ - "thiserror 2.0.18", + "thiserror 2.0.20", "windows 0.61.3", "windows-core 0.61.2", ] @@ -6410,7 +6774,7 @@ checksum = "12168c33176773b86799be25e2a2ba07c7aab9968b37541f1094dbd7a60c8946" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -6421,7 +6785,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -6432,7 +6796,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -6443,7 +6807,7 @@ checksum = "9d8dc32e0095a7eeccebd0e3f09e9509365ecb3fc6ac4d6f5f14a3f6392942d1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -6454,7 +6818,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -6465,7 +6829,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -6822,9 +7186,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] @@ -6855,7 +7219,7 @@ dependencies = [ "log", "os_pipe", "rustix", - "thiserror 2.0.18", + "thiserror 2.0.20", "tree_magic_mini", "wayland-backend", "wayland-client", @@ -6865,9 +7229,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wry" @@ -6902,7 +7266,7 @@ dependencies = [ "sha2", "soup3", "tao-macros", - "thiserror 2.0.18", + "thiserror 2.0.20", "url", "webkit2gtk", "webkit2gtk-sys", @@ -6963,9 +7327,9 @@ dependencies = [ [[package]] name = "xml" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "636f85e5ca6488e96401b61eb7de54f4e44755c988af0f52cf90230c312a1a89" +checksum = "2f45bb2c13fec6a6cb4c0f76a7e94839e110a14ec803ec2940777a94c347bc52" [[package]] name = "xml-rs" @@ -6989,21 +7353,21 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", "synstructure", ] [[package]] name = "zbus" -version = "5.16.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" dependencies = [ "async-broadcast", "async-executor", @@ -7028,7 +7392,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow 1.0.3", + "winnow 1.0.4", "zbus_macros", "zbus_names", "zvariant", @@ -7036,14 +7400,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.16.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", "zbus_names", "zvariant", "zvariant_utils", @@ -7051,33 +7415,42 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.2" +version = "4.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" dependencies = [ "serde", - "winnow 1.0.3", + "winnow 1.0.4", "zvariant", ] +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" -version = "0.8.52" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.52" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -7091,13 +7464,13 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", "synstructure", ] @@ -7109,9 +7482,9 @@ checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -7120,9 +7493,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -7131,13 +7504,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -7169,7 +7542,7 @@ dependencies = [ "arbitrary", "crc32fast", "flate2", - "indexmap 2.14.0", + "indexmap 2.14.2", "memchr", "zopfli", ] @@ -7182,21 +7555,21 @@ checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" dependencies = [ "arbitrary", "crc32fast", - "indexmap 2.14.0", + "indexmap 2.14.2", "memchr", ] [[package]] name = "zlib-rs" -version = "0.6.4" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "977347db8caa080403f6b6b7c1cda9479a8e869316f7e13a59b19076a40f94e3" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zopfli" @@ -7231,9 +7604,9 @@ dependencies = [ [[package]] name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" +version = "2.1.0+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" dependencies = [ "cc", "pkg-config", @@ -7241,9 +7614,9 @@ dependencies = [ [[package]] name = "zune-core" -version = "0.5.1" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" [[package]] name = "zune-jpeg" @@ -7256,40 +7629,41 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.12.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" dependencies = [ "endi", "enumflags2", "serde", - "winnow 1.0.3", + "winnow 1.0.4", + "zcheapstr", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.12.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.4.0" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.118", - "winnow 1.0.3", + "syn 3.0.6", + "winnow 1.0.4", ] diff --git a/Cargo.toml b/Cargo.toml index 6f128ca7..aa2b0853 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,12 @@ [workspace] resolver = "2" -members = ["crates/citadel-core", "crates/libcalibre", "src-tauri"] +members = [ + "crates/citadel-core", + "crates/citadel-opds", + "crates/citadel-server", + "crates/libcalibre", + "src-tauri", +] [workspace.package] rust-version = "1.97.1" diff --git a/crates/citadel-core/src/book.rs b/crates/citadel-core/src/book.rs index 2d1338d1..b7d38e15 100644 --- a/crates/citadel-core/src/book.rs +++ b/crates/citadel-core/src/book.rs @@ -82,7 +82,7 @@ impl LibraryBook { ) -> Self { Self { id: book.id.as_i32().to_string(), - uuid: Some(book.uuid.clone()), + uuid: book.uuid.clone(), title: book.title.clone(), author_list: book .authors diff --git a/crates/citadel-core/src/url.rs b/crates/citadel-core/src/url.rs index f7f3e60d..96be4d43 100644 --- a/crates/citadel-core/src/url.rs +++ b/crates/citadel-core/src/url.rs @@ -164,7 +164,7 @@ mod tests { ) -> libcalibre::library::Book { libcalibre::library::Book { id: libcalibre::BookId::from(1), - uuid: "test-uuid".to_string(), + uuid: Some("test-uuid".to_string()), title: "Title".to_string(), sortable_title: None, authors: vec![], diff --git a/crates/citadel-opds/Cargo.toml b/crates/citadel-opds/Cargo.toml new file mode 100644 index 00000000..86930092 --- /dev/null +++ b/crates/citadel-opds/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "citadel-opds" +version = "0.1.0" +edition = "2021" +rust-version.workspace = true +description = "Tauri-independent OPDS runtime for Citadel" + +[dependencies] +argon2 = "0.5" +axum = "0.8.9" +base64 = "0.22" +bytes = "1" +chrono = { version = "0.4.31", features = ["serde"] } +futures-util = "0.3" +hmac = "0.12" +rand_core = { version = "0.6", features = ["getrandom"] } +sha2 = "0.10" +subtle = "2.6" +libcalibre = { path = "../libcalibre" } +netdev = { version = "=0.45.0", default-features = false } +quick-xml = "0.38" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +specta = { version = "=2.0.0-rc.22", features = ["chrono", "derive"] } +tokio = { version = "1.52.3", features = ["macros", "net", "rt-multi-thread", "sync", "time"] } +urlencoding = "2.1.3" +uuid = { version = "1.6.1", features = ["v4", "fast-rng"] } + +[dev-dependencies] +diesel = { version = "2.1.0", features = ["sqlite"] } +reqwest = { version = "0.12", features = ["json"] } +tempfile = "3.8" +tower = "0.5" diff --git a/crates/citadel-opds/examples/network_interface_spike.rs b/crates/citadel-opds/examples/network_interface_spike.rs new file mode 100644 index 00000000..3bf5825c --- /dev/null +++ b/crates/citadel-opds/examples/network_interface_spike.rs @@ -0,0 +1,1244 @@ +use netdev::interface::types::InterfaceType; +use std::{ + collections::{BTreeMap, BTreeSet}, + env, + fmt::{self, Display}, + io, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV6, TcpListener}, + thread, + time::Duration, +}; + +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +struct InterfaceId(String); + +impl Display for InterfaceId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum InterfaceState { + Up, + Down, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum InterfaceClass { + Lan, + Vpn, + Loopback, + Other, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AddressScope { + Private, + Shared, + LinkLocal, + Loopback, + Global, + Other, + Unspecified, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct InterfaceAddress { + address: IpAddr, + prefix_length: u8, + scope: AddressScope, + scope_id: u32, + temporary: bool, + deprecated: bool, + tentative: bool, + duplicated: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct InterfaceSnapshot { + id: InterfaceId, + index: u32, + label: String, + administrative_up: bool, + running: bool, + state: InterfaceState, + class: InterfaceClass, + addresses: Vec, +} + +impl InterfaceSnapshot { + fn bindable_addresses(&self, port: u16) -> Vec { + self.addresses + .iter() + .filter_map(|address| match (address.address, address.scope) { + ( + IpAddr::V4(ip), + AddressScope::Private | AddressScope::Shared | AddressScope::Global, + ) => Some(SocketAddr::new(IpAddr::V4(ip), port)), + (IpAddr::V6(ip), AddressScope::Private | AddressScope::Global) + if !address.deprecated && !address.tentative && !address.duplicated => + { + Some(SocketAddr::V6(SocketAddrV6::new(ip, port, 0, 0))) + } + _ => None, + }) + .collect() + } +} + +#[derive(Clone, Debug)] +struct InterfaceFacts { + id: InterfaceId, + index: u32, + friendly_name: Option, + is_up: bool, + is_running: bool, + is_loopback: bool, + is_point_to_point: bool, + is_physical: bool, + kind: InterfaceType, + addresses: Vec, +} + +fn classify_interface(facts: &InterfaceFacts) -> InterfaceClass { + let name = facts.id.0.to_ascii_lowercase(); + let vpn_name = [ + "utun", + "tun", + "tap", + "wg", + "tailscale", + "proton", + "nordlynx", + "ipsec", + "ppp", + ] + .iter() + .any(|prefix| name.starts_with(prefix)); + let virtual_name = [ + "awdl", "llw", "bridge", "br-", "docker", "veth", "virbr", "vmenet", "vmnet", + ] + .iter() + .any(|prefix| name.starts_with(prefix)); + + if facts.is_loopback + || facts.kind == InterfaceType::Loopback + || (!facts.addresses.is_empty() + && facts + .addresses + .iter() + .all(|address| address.scope == AddressScope::Loopback)) + { + InterfaceClass::Loopback + } else if facts.is_point_to_point + || vpn_name + || matches!( + facts.kind, + InterfaceType::Tunnel | InterfaceType::Ppp | InterfaceType::ProprietaryVirtual + ) + { + InterfaceClass::Vpn + } else if virtual_name || facts.kind == InterfaceType::Bridge { + InterfaceClass::Other + } else if facts.is_physical + || matches!( + facts.kind, + InterfaceType::Ethernet + | InterfaceType::FastEthernetT + | InterfaceType::FastEthernetFx + | InterfaceType::GigabitEthernet + | InterfaceType::Wireless80211 + ) + { + InterfaceClass::Lan + } else { + InterfaceClass::Other + } +} + +fn snapshot_from_facts(facts: InterfaceFacts) -> InterfaceSnapshot { + let class = classify_interface(&facts); + let label = facts + .friendly_name + .filter(|label| !label.trim().is_empty()) + .unwrap_or_else(|| facts.id.0.clone()); + + InterfaceSnapshot { + id: facts.id, + index: facts.index, + label, + administrative_up: facts.is_up, + running: facts.is_running, + state: if facts.is_up && facts.is_running { + InterfaceState::Up + } else { + InterfaceState::Down + }, + class, + addresses: facts.addresses, + } +} + +fn ipv4_scope(address: Ipv4Addr) -> AddressScope { + let octets = address.octets(); + if address.is_unspecified() { + AddressScope::Unspecified + } else if address.is_loopback() { + AddressScope::Loopback + } else if address.is_link_local() { + AddressScope::LinkLocal + } else if address.is_private() { + AddressScope::Private + } else if octets[0] == 100 && (64..=127).contains(&octets[1]) { + AddressScope::Shared + } else if address.is_multicast() || address.is_broadcast() || octets[0] >= 240 { + AddressScope::Other + } else { + AddressScope::Global + } +} + +fn ipv6_scope(address: Ipv6Addr) -> AddressScope { + let first = address.segments()[0]; + if address.is_unspecified() { + AddressScope::Unspecified + } else if address.is_loopback() { + AddressScope::Loopback + } else if (first & 0xffc0) == 0xfe80 { + AddressScope::LinkLocal + } else if (first & 0xfe00) == 0xfc00 { + AddressScope::Private + } else if (first & 0xe000) == 0x2000 { + AddressScope::Global + } else { + AddressScope::Other + } +} + +fn advertised_url(address: SocketAddr) -> Option { + match address.ip() { + IpAddr::V4(ip) if !ip.is_unspecified() && !ip.is_loopback() && !ip.is_link_local() => { + Some(format!("http://{address}/opds")) + } + IpAddr::V6(ip) + if !ip.is_unspecified() + && !ip.is_loopback() + && (ip.segments()[0] & 0xffc0) != 0xfe80 => + { + Some(format!("http://{address}/opds")) + } + _ => None, + } +} + +#[cfg(test)] +fn scoped_link_local_socket(address: &InterfaceAddress, port: u16) -> Option { + match (address.address, address.scope, address.scope_id) { + (IpAddr::V6(ip), AddressScope::LinkLocal, scope_id) if scope_id != 0 => { + Some(SocketAddr::V6(SocketAddrV6::new(ip, port, 0, scope_id))) + } + _ => None, + } +} + +trait InterfaceProvider { + fn snapshot(&mut self) -> io::Result>; +} + +struct NetdevProvider; + +impl InterfaceProvider for NetdevProvider { + fn snapshot(&mut self) -> io::Result> { + Ok(netdev::get_interfaces() + .into_iter() + .map(|interface| { + let mut addresses = interface + .ipv4 + .iter() + .map(|network| InterfaceAddress { + address: IpAddr::V4(network.addr()), + prefix_length: network.prefix_len(), + scope: ipv4_scope(network.addr()), + scope_id: 0, + temporary: false, + deprecated: false, + tentative: false, + duplicated: false, + }) + .chain(interface.ipv6.iter().enumerate().map(|(index, network)| { + let flags = interface + .ipv6_addr_flags + .get(index) + .copied() + .unwrap_or_default(); + InterfaceAddress { + address: IpAddr::V6(network.addr()), + prefix_length: network.prefix_len(), + scope: ipv6_scope(network.addr()), + scope_id: interface.ipv6_scope_ids.get(index).copied().unwrap_or(0), + temporary: flags.temporary, + deprecated: flags.deprecated, + tentative: flags.tentative, + duplicated: flags.duplicated, + } + })) + .collect::>(); + addresses.sort_by_key(|address| address.address); + + snapshot_from_facts(InterfaceFacts { + id: InterfaceId(interface.name.clone()), + index: interface.index, + friendly_name: interface.friendly_name.clone(), + is_up: interface.is_up(), + is_running: interface.is_running(), + is_loopback: interface.is_loopback(), + is_point_to_point: interface.is_point_to_point(), + is_physical: interface.is_physical(), + kind: interface.if_type, + addresses, + }) + }) + .collect()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum BindTarget { + AllLocal, + Selected(InterfaceId), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum WaitingReason { + NoLocalInterface, + SelectedInterfaceMissing(InterfaceId), + SelectedInterfaceDown(InterfaceId), + SelectedInterfaceHasNoUsableAddress(InterfaceId), + LoopbackCannotBeShared(InterfaceId), +} + +impl Display for WaitingReason { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + WaitingReason::NoLocalInterface => { + formatter.write_str("no active local interface has a usable IP address") + } + WaitingReason::SelectedInterfaceMissing(id) => { + write!(formatter, "selected interface {id} is missing") + } + WaitingReason::SelectedInterfaceDown(id) => { + write!(formatter, "selected interface {id} is down") + } + WaitingReason::SelectedInterfaceHasNoUsableAddress(id) => { + write!( + formatter, + "selected interface {id} has no usable IP address" + ) + } + WaitingReason::LoopbackCannotBeShared(id) => { + write!(formatter, "selected interface {id} is loopback-only") + } + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum BindPlan { + Listen(BTreeSet), + Wait(WaitingReason), +} + +fn plan_bindings(interfaces: &[InterfaceSnapshot], target: &BindTarget, port: u16) -> BindPlan { + let selected = match target { + BindTarget::AllLocal => interfaces + .iter() + .filter(|interface| { + interface.class == InterfaceClass::Lan && interface.state == InterfaceState::Up + }) + .collect::>(), + BindTarget::Selected(id) => { + let Some(interface) = interfaces.iter().find(|interface| interface.id == *id) else { + return BindPlan::Wait(WaitingReason::SelectedInterfaceMissing(id.clone())); + }; + if interface.class == InterfaceClass::Loopback { + return BindPlan::Wait(WaitingReason::LoopbackCannotBeShared(id.clone())); + } + if interface.state != InterfaceState::Up { + return BindPlan::Wait(WaitingReason::SelectedInterfaceDown(id.clone())); + } + vec![interface] + } + }; + + let addresses = selected + .into_iter() + .flat_map(|interface| interface.bindable_addresses(port)) + .collect::>(); + + if !addresses.is_empty() { + BindPlan::Listen(addresses) + } else { + match target { + BindTarget::AllLocal => BindPlan::Wait(WaitingReason::NoLocalInterface), + BindTarget::Selected(id) => BindPlan::Wait( + WaitingReason::SelectedInterfaceHasNoUsableAddress(id.clone()), + ), + } + } +} + +trait ListenerFactory { + type Listener; + + fn bind(&mut self, address: SocketAddr) -> io::Result; + fn close(&mut self, address: SocketAddr, listener: Self::Listener); +} + +struct TcpListenerFactory; + +impl ListenerFactory for TcpListenerFactory { + type Listener = TcpListener; + + fn bind(&mut self, address: SocketAddr) -> io::Result { + let listener = TcpListener::bind(address)?; + listener.set_nonblocking(true)?; + Ok(listener) + } + + fn close(&mut self, _address: SocketAddr, listener: Self::Listener) { + drop(listener); + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum ProbeStatus { + Running(BTreeSet), + Waiting(WaitingReason), + Error(String), +} + +struct ListenerProbe +where + Provider: InterfaceProvider, + Factory: ListenerFactory, +{ + provider: Provider, + factory: Factory, + target: BindTarget, + port: u16, + listeners: BTreeMap, + status: ProbeStatus, +} + +impl ListenerProbe +where + Provider: InterfaceProvider, + Factory: ListenerFactory, +{ + fn new(provider: Provider, factory: Factory, target: BindTarget, port: u16) -> Self { + Self { + provider, + factory, + target, + port, + listeners: BTreeMap::new(), + status: ProbeStatus::Waiting(WaitingReason::NoLocalInterface), + } + } + + fn tick(&mut self) -> &ProbeStatus { + let interfaces = match self.provider.snapshot() { + Ok(interfaces) => interfaces, + Err(error) => { + self.close_all(); + self.status = ProbeStatus::Error(format!("interface enumeration failed: {error}")); + return &self.status; + } + }; + match plan_bindings(&interfaces, &self.target, self.port) { + BindPlan::Wait(reason) => { + self.close_all(); + self.status = ProbeStatus::Waiting(reason); + } + BindPlan::Listen(desired) => { + self.reconcile(desired); + } + } + &self.status + } + + fn reconcile(&mut self, desired: BTreeSet) { + let stale = self + .listeners + .keys() + .filter(|address| !desired.contains(address)) + .copied() + .collect::>(); + for address in stale { + if let Some(listener) = self.listeners.remove(&address) { + self.factory.close(address, listener); + } + } + + for address in desired + .iter() + .filter(|address| !self.listeners.contains_key(address)) + .copied() + .collect::>() + { + if address.ip().is_unspecified() { + self.close_all(); + self.status = ProbeStatus::Error("refusing wildcard listener".to_string()); + return; + } + match self.factory.bind(address) { + Ok(listener) => { + self.listeners.insert(address, listener); + } + Err(error) => { + self.close_all(); + self.status = ProbeStatus::Error(format!("could not bind {address}: {error}")); + return; + } + } + } + + self.status = ProbeStatus::Running(desired); + } + + fn close_all(&mut self) { + let listeners = std::mem::take(&mut self.listeners); + for (address, listener) in listeners { + self.factory.close(address, listener); + } + } +} + +impl Drop for ListenerProbe +where + Provider: InterfaceProvider, + Factory: ListenerFactory, +{ + fn drop(&mut self) { + self.close_all(); + } +} + +fn print_interfaces(interfaces: &[InterfaceSnapshot]) { + for interface in interfaces { + println!( + "{} label={:?} index={} state={:?} administrative_up={} running={} class={:?}", + interface.id, + interface.label, + interface.index, + interface.state, + interface.administrative_up, + interface.running, + interface.class + ); + for address in &interface.addresses { + println!( + " {}/{} scope={:?} scope_id={} temporary={} deprecated={} tentative={} duplicated={}", + address.address, + address.prefix_length, + address.scope, + address.scope_id, + address.temporary, + address.deprecated, + address.tentative, + address.duplicated, + ); + } + } +} + +fn parse_port(value: &str) -> Result { + let port = value + .parse::() + .map_err(|_| "--port must be between 1 and 65535".to_string())?; + if port == 0 { + return Err("--port must be between 1 and 65535".to_string()); + } + Ok(port) +} + +fn parse_args() -> Result<(bool, BindTarget, u16, u64), String> { + let mut list_only = false; + let mut target = BindTarget::AllLocal; + let mut port = 8080; + let mut ticks = 1; + let mut args = env::args().skip(1); + + while let Some(argument) = args.next() { + match argument.as_str() { + "--list" => list_only = true, + "--target" => { + let value = args + .next() + .ok_or("--target requires all or an interface name")?; + target = if value == "all" { + BindTarget::AllLocal + } else { + BindTarget::Selected(InterfaceId(value)) + }; + } + "--port" => { + port = parse_port(&args.next().ok_or("--port requires a number")?)?; + } + "--ticks" => { + ticks = args + .next() + .ok_or("--ticks requires a number")? + .parse() + .map_err(|_| "--ticks must be a positive integer")?; + if ticks == 0 { + return Err("--ticks must be a positive integer".to_string()); + } + } + _ => return Err(format!("unknown argument: {argument}")), + } + } + + Ok((list_only, target, port, ticks)) +} + +fn run() -> Result<(), String> { + let (list_only, target, port, ticks) = parse_args()?; + if list_only { + let mut provider = NetdevProvider; + let interfaces = provider.snapshot().map_err(|error| error.to_string())?; + print_interfaces(&interfaces); + return Ok(()); + } + + let mut probe = ListenerProbe::new(NetdevProvider, TcpListenerFactory, target, port); + for tick in 0..ticks { + let status = probe.tick().clone(); + println!("tick {}: {status:?}", tick + 1); + if let ProbeStatus::Running(addresses) = status { + for url in addresses.into_iter().filter_map(advertised_url) { + println!(" {url}"); + } + } + if tick + 1 < ticks { + thread::sleep(Duration::from_secs(1)); + } + } + Ok(()) +} + +fn main() { + if let Err(error) = run() { + eprintln!( + "{error}\nusage: network_interface_spike [--list] [--target all|NAME] [--port PORT] [--ticks N]" + ); + std::process::exit(2); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{cell::RefCell, collections::VecDeque, rc::Rc}; + + fn address(address: &str, prefix_length: u8) -> InterfaceAddress { + let address = address.parse::().unwrap(); + let scope = match address { + IpAddr::V4(address) => ipv4_scope(address), + IpAddr::V6(address) => ipv6_scope(address), + }; + InterfaceAddress { + address, + prefix_length, + scope, + scope_id: 0, + temporary: false, + deprecated: false, + tentative: false, + duplicated: false, + } + } + + fn temporary_address(address_value: &str, prefix_length: u8) -> InterfaceAddress { + let mut address = address(address_value, prefix_length); + address.temporary = true; + address + } + + fn scoped_address(address_value: &str, prefix_length: u8, scope_id: u32) -> InterfaceAddress { + let mut address = address(address_value, prefix_length); + address.scope_id = scope_id; + address + } + + fn interface( + id: &str, + state: InterfaceState, + class: InterfaceClass, + addresses: &[(&str, u8)], + ) -> InterfaceSnapshot { + interface_with_addresses( + id, + state, + class, + addresses + .iter() + .map(|(value, prefix)| address(value, *prefix)) + .collect(), + ) + } + + fn interface_with_addresses( + id: &str, + state: InterfaceState, + class: InterfaceClass, + addresses: Vec, + ) -> InterfaceSnapshot { + InterfaceSnapshot { + id: InterfaceId(id.to_string()), + index: 7, + label: id.to_string(), + administrative_up: state == InterfaceState::Up, + running: state == InterfaceState::Up, + state, + class, + addresses, + } + } + + struct SequenceProvider { + snapshots: VecDeque>>, + } + + impl InterfaceProvider for SequenceProvider { + fn snapshot(&mut self) -> io::Result> { + self.snapshots + .pop_front() + .expect("test requested an unexpected snapshot") + } + } + + #[derive(Clone, Debug, Eq, PartialEq)] + enum ListenerEvent { + Bind(SocketAddr), + Close(SocketAddr), + } + + struct FakeFactory { + events: Rc>>, + fail_on: Option, + } + + impl ListenerFactory for FakeFactory { + type Listener = (); + + fn bind(&mut self, address: SocketAddr) -> io::Result { + self.events.borrow_mut().push(ListenerEvent::Bind(address)); + if self.fail_on == Some(address) { + Err(io::Error::new( + io::ErrorKind::AddrInUse, + "injected bind failure", + )) + } else { + Ok(()) + } + } + + fn close(&mut self, address: SocketAddr, _listener: Self::Listener) { + self.events.borrow_mut().push(ListenerEvent::Close(address)); + } + } + + fn probe( + snapshots: Vec>, + target: BindTarget, + ) -> ( + ListenerProbe, + Rc>>, + ) { + let events = Rc::new(RefCell::new(Vec::new())); + ( + ListenerProbe::new( + SequenceProvider { + snapshots: snapshots.into_iter().map(Ok).collect(), + }, + FakeFactory { + events: events.clone(), + fail_on: None, + }, + target, + 8080, + ), + events, + ) + } + + #[test] + fn preserves_stable_name_and_friendly_label_while_classifying_lan() { + let snapshot = snapshot_from_facts(InterfaceFacts { + id: InterfaceId("en0".to_string()), + index: 12, + friendly_name: Some("Wi-Fi".to_string()), + is_up: true, + is_running: true, + is_loopback: false, + is_point_to_point: false, + is_physical: true, + kind: InterfaceType::Wireless80211, + addresses: vec![address("192.168.1.20", 24), address("fe80::1", 64)], + }); + + assert_eq!(snapshot.id, InterfaceId("en0".to_string())); + assert_eq!(snapshot.index, 12); + assert_eq!(snapshot.label, "Wi-Fi"); + assert!(snapshot.administrative_up); + assert!(snapshot.running); + assert_eq!(snapshot.state, InterfaceState::Up); + assert_eq!(snapshot.class, InterfaceClass::Lan); + assert_eq!(snapshot.addresses[1].scope, AddressScope::LinkLocal); + } + + #[test] + fn classifies_loopback_vpn_and_virtual_interfaces_before_physical_fallback() { + let facts = |id: &str, kind, point_to_point, physical| InterfaceFacts { + id: InterfaceId(id.to_string()), + index: 1, + friendly_name: None, + is_up: true, + is_running: true, + is_loopback: id == "lo0", + is_point_to_point: point_to_point, + is_physical: physical, + kind, + addresses: vec![address( + if id == "lo0" { "127.0.0.1" } else { "10.0.0.2" }, + 24, + )], + }; + + assert_eq!( + classify_interface(&facts("lo0", InterfaceType::Loopback, false, false)), + InterfaceClass::Loopback + ); + assert_eq!( + classify_interface(&facts("utun4", InterfaceType::Ethernet, true, true)), + InterfaceClass::Vpn + ); + assert_eq!( + classify_interface(&facts("docker0", InterfaceType::Ethernet, false, true)), + InterfaceClass::Other + ); + assert_eq!( + classify_interface(&facts("vmenet0", InterfaceType::Ethernet, false, true)), + InterfaceClass::Other + ); + let mut addressless = facts("anpi0", InterfaceType::Unknown, false, false); + addressless.addresses.clear(); + assert_eq!(classify_interface(&addressless), InterfaceClass::Other); + } + + #[test] + fn all_local_binds_every_usable_ipv4_global_and_ula_ipv6_address() { + let interfaces = vec![ + interface_with_addresses( + "en0", + InterfaceState::Up, + InterfaceClass::Lan, + vec![ + address("192.168.1.20", 24), + address("2001:db8::20", 64), + temporary_address("2001:db8::21", 64), + address("fd00::20", 64), + scoped_address("fe80::1", 64, 14), + address("ff02::1", 16), + ], + ), + interface( + "eth0", + InterfaceState::Up, + InterfaceClass::Lan, + &[("10.0.0.10", 24)], + ), + interface( + "utun4", + InterfaceState::Up, + InterfaceClass::Vpn, + &[("100.80.0.2", 32)], + ), + interface( + "lo0", + InterfaceState::Up, + InterfaceClass::Loopback, + &[("127.0.0.1", 8)], + ), + ]; + + let BindPlan::Listen(addresses) = plan_bindings(&interfaces, &BindTarget::AllLocal, 8080) + else { + panic!("expected listeners"); + }; + assert_eq!( + addresses, + [ + "10.0.0.10:8080".parse().unwrap(), + "192.168.1.20:8080".parse().unwrap(), + "[2001:db8::20]:8080".parse().unwrap(), + "[2001:db8::21]:8080".parse().unwrap(), + "[fd00::20]:8080".parse().unwrap(), + ] + .into_iter() + .collect() + ); + assert!(addresses + .iter() + .all(|address| !address.ip().is_unspecified())); + } + + #[test] + fn advertised_urls_bracket_ipv6_literals() { + assert_eq!( + advertised_url("192.168.1.20:8080".parse().unwrap()), + Some("http://192.168.1.20:8080/opds".to_string()) + ); + assert_eq!( + advertised_url("[2001:db8::20]:8080".parse().unwrap()), + Some("http://[2001:db8::20]:8080/opds".to_string()) + ); + assert_eq!( + advertised_url("[fd00::20]:8080".parse().unwrap()), + Some("http://[fd00::20]:8080/opds".to_string()) + ); + } + + #[test] + fn configured_port_must_not_be_ephemeral() { + assert_eq!(parse_port("8080"), Ok(8080)); + assert!(parse_port("0").is_err()); + assert!(parse_port("65536").is_err()); + } + + #[test] + fn link_local_scope_id_is_retained_for_diagnostics_but_not_advertised() { + let address = scoped_address("fe80::1", 64, 14); + let socket = scoped_link_local_socket(&address, 8080).unwrap(); + + assert_eq!(socket.to_string(), "[fe80::1%14]:8080"); + assert_eq!(advertised_url(socket), None); + assert!(interface_with_addresses( + "en0", + InterfaceState::Up, + InterfaceClass::Lan, + vec![address], + ) + .bindable_addresses(8080) + .is_empty()); + } + + #[test] + fn unusable_ipv6_address_states_are_not_bound() { + let mut deprecated = address("2001:db8::40", 64); + deprecated.deprecated = true; + let mut tentative = address("2001:db8::41", 64); + tentative.tentative = true; + let mut duplicated = address("2001:db8::42", 64); + duplicated.duplicated = true; + + assert!(interface_with_addresses( + "en0", + InterfaceState::Up, + InterfaceClass::Lan, + vec![deprecated, tentative, duplicated], + ) + .bindable_addresses(8080) + .is_empty()); + } + + #[test] + fn explicitly_selected_vpn_is_allowed() { + let interfaces = vec![interface( + "wg0", + InterfaceState::Up, + InterfaceClass::Vpn, + &[("10.8.0.4", 24)], + )]; + + assert_eq!( + plan_bindings( + &interfaces, + &BindTarget::Selected(InterfaceId("wg0".to_string())), + 8080 + ), + BindPlan::Listen(["10.8.0.4:8080".parse().unwrap()].into_iter().collect()) + ); + } + + #[test] + fn selected_interface_disappearance_closes_then_recovers_without_widening() { + let selected = BindTarget::Selected(InterfaceId("en0".to_string())); + let (mut probe, events) = probe( + vec![ + vec![interface( + "en0", + InterfaceState::Up, + InterfaceClass::Lan, + &[("192.168.1.20", 24)], + )], + vec![], + vec![interface( + "en0", + InterfaceState::Up, + InterfaceClass::Lan, + &[("192.168.1.45", 24)], + )], + ], + selected, + ); + + assert!(matches!(probe.tick(), ProbeStatus::Running(_))); + assert!(matches!( + probe.tick(), + ProbeStatus::Waiting(WaitingReason::SelectedInterfaceMissing(_)) + )); + assert!(matches!(probe.tick(), ProbeStatus::Running(_))); + assert_eq!( + *events.borrow(), + vec![ + ListenerEvent::Bind("192.168.1.20:8080".parse().unwrap()), + ListenerEvent::Close("192.168.1.20:8080".parse().unwrap()), + ListenerEvent::Bind("192.168.1.45:8080".parse().unwrap()), + ] + ); + assert!(events.borrow().iter().all(|event| match event { + ListenerEvent::Bind(address) | ListenerEvent::Close(address) => + !address.ip().is_unspecified(), + })); + } + + #[test] + fn selected_interface_down_waits_without_falling_back_to_another_lan() { + let interfaces = vec![ + interface( + "en0", + InterfaceState::Down, + InterfaceClass::Lan, + &[("192.168.1.20", 24)], + ), + interface( + "en1", + InterfaceState::Up, + InterfaceClass::Lan, + &[("10.0.0.10", 24)], + ), + ]; + + assert_eq!( + plan_bindings( + &interfaces, + &BindTarget::Selected(InterfaceId("en0".to_string())), + 8080 + ), + BindPlan::Wait(WaitingReason::SelectedInterfaceDown(InterfaceId( + "en0".to_string() + ))) + ); + } + + #[test] + fn address_change_closes_stale_listener_before_opening_replacement() { + let (mut probe, events) = probe( + vec![ + vec![interface( + "en0", + InterfaceState::Up, + InterfaceClass::Lan, + &[("192.168.1.20", 24)], + )], + vec![interface( + "en0", + InterfaceState::Up, + InterfaceClass::Lan, + &[("192.168.1.45", 24)], + )], + ], + BindTarget::Selected(InterfaceId("en0".to_string())), + ); + + probe.tick(); + probe.tick(); + + assert_eq!( + *events.borrow(), + vec![ + ListenerEvent::Bind("192.168.1.20:8080".parse().unwrap()), + ListenerEvent::Close("192.168.1.20:8080".parse().unwrap()), + ListenerEvent::Bind("192.168.1.45:8080".parse().unwrap()), + ] + ); + } + + #[test] + fn temporary_ipv6_rotation_reconciles_without_restarting_stable_address() { + let (mut probe, events) = probe( + vec![ + vec![interface_with_addresses( + "en0", + InterfaceState::Up, + InterfaceClass::Lan, + vec![ + address("2001:db8::20", 64), + temporary_address("2001:db8::30", 64), + ], + )], + vec![interface_with_addresses( + "en0", + InterfaceState::Up, + InterfaceClass::Lan, + vec![ + address("2001:db8::20", 64), + temporary_address("2001:db8::31", 64), + ], + )], + ], + BindTarget::Selected(InterfaceId("en0".to_string())), + ); + + probe.tick(); + probe.tick(); + + assert_eq!( + *events.borrow(), + vec![ + ListenerEvent::Bind("[2001:db8::20]:8080".parse().unwrap()), + ListenerEvent::Bind("[2001:db8::30]:8080".parse().unwrap()), + ListenerEvent::Close("[2001:db8::30]:8080".parse().unwrap()), + ListenerEvent::Bind("[2001:db8::31]:8080".parse().unwrap()), + ] + ); + } + + #[test] + fn ipv6_only_interface_disappearance_fails_closed_then_recovers() { + let selected = BindTarget::Selected(InterfaceId("en0".to_string())); + let (mut probe, events) = probe( + vec![ + vec![interface( + "en0", + InterfaceState::Up, + InterfaceClass::Lan, + &[("fd00::20", 64)], + )], + vec![interface( + "en0", + InterfaceState::Up, + InterfaceClass::Lan, + &[("fe80::1", 64)], + )], + vec![interface( + "en0", + InterfaceState::Up, + InterfaceClass::Lan, + &[("fd00::45", 64)], + )], + ], + selected, + ); + + assert!(matches!(probe.tick(), ProbeStatus::Running(_))); + assert!(matches!( + probe.tick(), + ProbeStatus::Waiting(WaitingReason::SelectedInterfaceHasNoUsableAddress(_)) + )); + assert!(matches!(probe.tick(), ProbeStatus::Running(_))); + assert_eq!( + *events.borrow(), + vec![ + ListenerEvent::Bind("[fd00::20]:8080".parse().unwrap()), + ListenerEvent::Close("[fd00::20]:8080".parse().unwrap()), + ListenerEvent::Bind("[fd00::45]:8080".parse().unwrap()), + ] + ); + } + + #[test] + fn enumeration_error_closes_existing_listener() { + let events = Rc::new(RefCell::new(Vec::new())); + let mut probe = ListenerProbe::new( + SequenceProvider { + snapshots: [ + Ok(vec![interface( + "en0", + InterfaceState::Up, + InterfaceClass::Lan, + &[("192.168.1.20", 24)], + )]), + Err(io::Error::other("injected enumeration failure")), + ] + .into_iter() + .collect(), + }, + FakeFactory { + events: events.clone(), + fail_on: None, + }, + BindTarget::AllLocal, + 8080, + ); + + probe.tick(); + assert_eq!( + probe.tick(), + &ProbeStatus::Error( + "interface enumeration failed: injected enumeration failure".to_string() + ) + ); + assert!(probe.listeners.is_empty()); + assert_eq!( + *events.borrow(), + vec![ + ListenerEvent::Bind("192.168.1.20:8080".parse().unwrap()), + ListenerEvent::Close("192.168.1.20:8080".parse().unwrap()), + ] + ); + } + + #[test] + fn bind_failure_closes_every_listener_and_reports_error() { + let events = Rc::new(RefCell::new(Vec::new())); + let failing_address = "192.168.1.21:8080".parse().unwrap(); + let mut probe = ListenerProbe::new( + SequenceProvider { + snapshots: [Ok(vec![interface( + "en0", + InterfaceState::Up, + InterfaceClass::Lan, + &[("192.168.1.20", 24), ("192.168.1.21", 24)], + )])] + .into_iter() + .collect(), + }, + FakeFactory { + events: events.clone(), + fail_on: Some(failing_address), + }, + BindTarget::AllLocal, + 8080, + ); + + assert_eq!( + probe.tick(), + &ProbeStatus::Error(format!( + "could not bind {failing_address}: injected bind failure" + )) + ); + assert!(probe.listeners.is_empty()); + assert_eq!( + *events.borrow(), + vec![ + ListenerEvent::Bind("192.168.1.20:8080".parse().unwrap()), + ListenerEvent::Bind(failing_address), + ListenerEvent::Close("192.168.1.20:8080".parse().unwrap()), + ] + ); + } +} diff --git a/crates/citadel-opds/src/assets.rs b/crates/citadel-opds/src/assets.rs new file mode 100644 index 00000000..189a4f93 --- /dev/null +++ b/crates/citadel-opds/src/assets.rs @@ -0,0 +1,448 @@ +use std::{ + fs::File, + io::{self, Read, Seek, SeekFrom, Take}, + path::Path, +}; + +use libcalibre::ResolvedBookAsset; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AssetMethod { + Get, + Head, +} + +#[derive(Debug, Eq, PartialEq)] +pub struct AssetHeaders { + pub accept_ranges: &'static str, + pub content_disposition: String, + pub content_length: u64, + pub content_range: Option, + pub content_type: &'static str, +} + +/// A bounded synchronous file stream. CDL-23 can move reads onto its HTTP +/// runtime's blocking adapter without coupling asset resolution to a framework. +pub struct AssetBody { + reader: Take, +} + +impl Read for AssetBody { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + self.reader.read(buffer) + } +} + +pub struct AssetResponse { + pub status: u16, + pub headers: AssetHeaders, + pub body: Option, +} + +#[derive(Debug)] +pub enum AssetResponseError { + Io(io::Error), + RangeNotSatisfiable { length: u64 }, +} + +impl std::fmt::Display for AssetResponseError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(error) => write!(formatter, "Could not open asset: {error}"), + Self::RangeNotSatisfiable { length } => { + write!( + formatter, + "Range is not satisfiable for a {length}-byte asset" + ) + } + } + } +} + +impl std::error::Error for AssetResponseError {} + +impl AssetResponseError { + pub fn status(&self) -> u16 { + match self { + Self::Io(_) => 500, + Self::RangeNotSatisfiable { .. } => 416, + } + } + + pub fn content_range(&self) -> Option { + match self { + Self::RangeNotSatisfiable { length } => Some(format!("bytes */{length}")), + Self::Io(_) => None, + } + } +} + +impl From for AssetResponseError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct SelectedRange { + start: u64, + end: u64, +} + +impl SelectedRange { + fn length(self) -> u64 { + self.end - self.start + 1 + } +} + +pub fn prepare( + asset: &ResolvedBookAsset, + method: AssetMethod, + range_header: Option<&str>, +) -> Result { + prepare_path( + asset.canonical_path(), + asset.download_name(), + asset.format(), + method, + range_header, + ) +} + +fn prepare_path( + path: &Path, + download_name: &str, + format: &str, + method: AssetMethod, + range_header: Option<&str>, +) -> Result { + let mut file = File::open(path)?; + let full_length = file.metadata()?.len(); + let selected = match range_header { + Some(header) => Some(parse_range(header, full_length)?), + None => None, + }; + let (status, start, content_length, content_range) = match selected { + Some(range) => ( + 206, + range.start, + range.length(), + Some(format!( + "bytes {}-{}/{}", + range.start, range.end, full_length + )), + ), + None => (200, 0, full_length, None), + }; + + let body = if method == AssetMethod::Get { + file.seek(SeekFrom::Start(start))?; + Some(AssetBody { + reader: file.take(content_length), + }) + } else { + None + }; + + Ok(AssetResponse { + status, + headers: AssetHeaders { + accept_ranges: "bytes", + content_disposition: content_disposition(download_name), + content_length, + content_range, + content_type: mime_type(format), + }, + body, + }) +} + +fn parse_range(header: &str, length: u64) -> Result { + let invalid = || AssetResponseError::RangeNotSatisfiable { length }; + let value = header + .strip_prefix("bytes=") + .filter(|value| !value.contains(',')) + .ok_or_else(invalid)?; + let (start, end) = value.split_once('-').ok_or_else(invalid)?; + if length == 0 { + return Err(invalid()); + } + + match (start.is_empty(), end.is_empty()) { + (false, false) => { + let start = start.parse::().map_err(|_| invalid())?; + let requested_end = end.parse::().map_err(|_| invalid())?; + if start >= length || start > requested_end { + return Err(invalid()); + } + Ok(SelectedRange { + start, + end: requested_end.min(length - 1), + }) + } + (false, true) => { + let start = start.parse::().map_err(|_| invalid())?; + if start >= length { + return Err(invalid()); + } + Ok(SelectedRange { + start, + end: length - 1, + }) + } + (true, false) => { + let suffix = end.parse::().map_err(|_| invalid())?; + if suffix == 0 { + return Err(invalid()); + } + Ok(SelectedRange { + start: length.saturating_sub(suffix), + end: length - 1, + }) + } + (true, true) => Err(invalid()), + } +} + +pub fn mime_type(format: &str) -> &'static str { + match format.to_ascii_lowercase().as_str() { + "epub" => "application/epub+zip", + "mobi" => "application/x-mobipocket-ebook", + "azw" => "application/vnd.amazon.ebook", + "azw3" => "application/vnd.amazon.ebook-kf8", + "pdf" => "application/pdf", + "txt" => "text/plain; charset=utf-8", + "html" | "htm" => "text/html; charset=utf-8", + "cbz" => "application/vnd.comicbook+zip", + "cbr" => "application/vnd.comicbook-rar", + "fb2" => "application/x-fictionbook+xml", + "djvu" | "djv" => "image/vnd.djvu", + "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "odt" => "application/vnd.oasis.opendocument.text", + "rtf" => "application/rtf", + "jpg" | "jpeg" => "image/jpeg", + "png" => "image/png", + "gif" => "image/gif", + "webp" => "image/webp", + _ => "application/octet-stream", + } +} + +fn content_disposition(filename: &str) -> String { + let fallback: String = filename + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() + || matches!(character, ' ' | '.' | '-' | '_' | '(' | ')') + { + character + } else { + '_' + } + }) + .collect(); + let encoded = percent_encode(filename.as_bytes()); + format!("attachment; filename=\"{fallback}\"; filename*=UTF-8''{encoded}") +} + +fn percent_encode(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + let mut encoded = String::with_capacity(bytes.len()); + for &byte in bytes { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + encoded.push(char::from(byte)); + } else { + encoded.push('%'); + encoded.push(char::from(HEX[usize::from(byte >> 4)])); + encoded.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + } + encoded +} + +#[cfg(test)] +mod tests { + use std::io::Read; + + use super::*; + + fn file(bytes: &[u8]) -> tempfile::NamedTempFile { + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file.path(), bytes).unwrap(); + file + } + + #[test] + fn get_streams_the_complete_file_with_download_headers() { + let file = file(b"0123456789"); + let mut response = + prepare_path(file.path(), "A Book.epub", "EPUB", AssetMethod::Get, None).unwrap(); + assert_eq!(response.status, 200); + assert_eq!(response.headers.content_length, 10); + assert_eq!(response.headers.content_type, "application/epub+zip"); + assert_eq!(response.headers.accept_ranges, "bytes"); + assert_eq!(response.headers.content_range, None); + let mut bytes = Vec::new(); + response + .body + .as_mut() + .unwrap() + .read_to_end(&mut bytes) + .unwrap(); + assert_eq!(bytes, b"0123456789"); + } + + #[test] + fn head_has_get_headers_and_no_body() { + let file = file(b"0123456789"); + let response = + prepare_path(file.path(), "A Book.pdf", "PDF", AssetMethod::Head, None).unwrap(); + assert_eq!(response.status, 200); + assert_eq!(response.headers.content_length, 10); + assert_eq!(response.headers.content_type, "application/pdf"); + assert!(response.body.is_none()); + } + + #[test] + fn streams_closed_open_and_suffix_ranges() { + let cases = [ + ("bytes=2-5", "bytes 2-5/10", b"2345".as_slice()), + ("bytes=7-", "bytes 7-9/10", b"789".as_slice()), + ("bytes=-3", "bytes 7-9/10", b"789".as_slice()), + ("bytes=8-99", "bytes 8-9/10", b"89".as_slice()), + ]; + for (header, expected_range, expected_bytes) in cases { + let file = file(b"0123456789"); + let mut response = prepare_path( + file.path(), + "book.epub", + "epub", + AssetMethod::Get, + Some(header), + ) + .unwrap(); + assert_eq!(response.status, 206); + assert_eq!( + response.headers.content_range.as_deref(), + Some(expected_range) + ); + assert_eq!(response.headers.content_length, expected_bytes.len() as u64); + let mut bytes = Vec::new(); + response + .body + .as_mut() + .unwrap() + .read_to_end(&mut bytes) + .unwrap(); + assert_eq!(bytes, expected_bytes); + } + } + + #[test] + fn head_honors_ranges_without_opening_a_body() { + let file = file(b"0123456789"); + let response = prepare_path( + file.path(), + "book.epub", + "epub", + AssetMethod::Head, + Some("bytes=2-5"), + ) + .unwrap(); + assert_eq!(response.status, 206); + assert_eq!(response.headers.content_length, 4); + assert_eq!( + response.headers.content_range.as_deref(), + Some("bytes 2-5/10") + ); + assert!(response.body.is_none()); + } + + #[test] + fn rejects_invalid_unsatisfiable_and_multiple_ranges_as_416() { + for header in [ + "items=0-1", + "bytes=", + "bytes=20-30", + "bytes=7-2", + "bytes=-0", + "bytes=0-1,4-5", + ] { + let file = file(b"0123456789"); + let error = prepare_path(file.path(), "book", "bin", AssetMethod::Get, Some(header)) + .err() + .unwrap(); + assert!(matches!( + &error, + AssetResponseError::RangeNotSatisfiable { length: 10 } + )); + assert_eq!(error.status(), 416); + assert_eq!(error.content_range().as_deref(), Some("bytes */10")); + } + } + + #[test] + fn empty_assets_reject_ranges_but_support_full_get() { + let file = file(b""); + let response = prepare_path(file.path(), "empty", "bin", AssetMethod::Get, None).unwrap(); + assert_eq!(response.status, 200); + assert_eq!(response.headers.content_length, 0); + assert!(matches!( + prepare_path( + file.path(), + "empty", + "bin", + AssetMethod::Get, + Some("bytes=0-") + ), + Err(AssetResponseError::RangeNotSatisfiable { length: 0 }) + )); + } + + #[test] + fn content_disposition_cannot_inject_headers_and_preserves_unicode() { + let header = content_disposition("Résumé\"\r\nX-Evil: yes.epub"); + assert!(!header.contains('\r')); + assert!(!header.contains('\n')); + assert!(header.contains("filename=\"R_sum____X-Evil_ yes.epub\"")); + assert!(header.contains("filename*=UTF-8''R%C3%A9sum%C3%A9%22%0D%0AX-Evil%3A%20yes.epub")); + } + + #[test] + fn maps_known_types_and_falls_back_for_unknown_formats() { + assert_eq!(mime_type("AZW3"), "application/vnd.amazon.ebook-kf8"); + assert_eq!(mime_type("cbz"), "application/vnd.comicbook+zip"); + assert_eq!(mime_type("unexpected"), "application/octet-stream"); + } + + #[test] + fn cover_assets_flow_through_the_same_response_preparation() { + let file = file(b"jpeg"); + let response = + prepare_path(file.path(), "cover.jpg", "JPG", AssetMethod::Get, None).unwrap(); + assert_eq!(response.status, 200); + assert_eq!(response.headers.content_type, "image/jpeg"); + assert!(response + .headers + .content_disposition + .contains("filename=\"cover.jpg\"")); + } + + #[test] + fn large_file_body_is_a_bounded_stream_not_an_allocated_payload() { + let file = file(b""); + file.as_file().set_len(64 * 1024 * 1024).unwrap(); + let mut response = + prepare_path(file.path(), "large.epub", "epub", AssetMethod::Get, None).unwrap(); + assert_eq!(response.headers.content_length, 64 * 1024 * 1024); + + let mut first_chunk = [0_u8; 1024]; + let read = response + .body + .as_mut() + .unwrap() + .read(&mut first_chunk) + .unwrap(); + assert_eq!(read, first_chunk.len()); + } +} diff --git a/crates/citadel-opds/src/auth.rs b/crates/citadel-opds/src/auth.rs new file mode 100644 index 00000000..fece9ec7 --- /dev/null +++ b/crates/citadel-opds/src/auth.rs @@ -0,0 +1,510 @@ +use std::{ + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; + +use argon2::{ + password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Algorithm, Argon2, Params, Version, +}; +use axum::{ + extract::{Request, State}, + http::{header::AUTHORIZATION, header::WWW_AUTHENTICATE, HeaderValue, StatusCode}, + middleware::Next, + response::{IntoResponse, Response}, +}; +use base64::{engine::general_purpose::STANDARD, Engine}; +use hmac::{Hmac, Mac}; +use rand_core::{OsRng, RngCore}; +use sha2::Sha256; +use subtle::ConstantTimeEq; + +const DEFAULT_CACHE_CAPACITY: usize = 256; +const DEFAULT_CACHE_TTL: Duration = Duration::from_secs(30); +const DEFAULT_TARGET_DURATION: Duration = Duration::from_millis(250); +const BASIC_CHALLENGE: &str = "Basic realm=\"Citadel\""; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct OpdsAuthCredentials { + pub username: String, + pub verifier: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum OpdsAuthError { + InvalidVerifier, + PasswordHashingFailed, +} + +impl std::fmt::Display for OpdsAuthError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidVerifier => { + formatter.write_str("The OPDS credential verifier is invalid.") + } + Self::PasswordHashingFailed => { + formatter.write_str("Could not create OPDS credentials.") + } + } + } +} + +impl std::error::Error for OpdsAuthError {} + +/// Creates credentials which store only a username and an Argon2id PHC verifier. +pub(crate) fn create_credentials( + username: String, + password: &[u8], +) -> Result { + let salt = SaltString::generate(&mut OsRng); + let verifier = argon2id() + .hash_password(password, &salt) + .map_err(|_| OpdsAuthError::PasswordHashingFailed)? + .to_string(); + + Ok(OpdsAuthCredentials { username, verifier }) +} + +#[derive(Clone)] +pub struct OpdsBasicAuth { + enabled: Option>, +} + +struct EnabledAuth { + credentials: OpdsAuthCredentials, + cache_key: [u8; 32], + cache: Mutex, + target_duration: Mutex, +} + +#[derive(Clone, Copy)] +enum CachedOutcome { + Authorized, + Rejected, +} + +struct CacheEntry { + tag: [u8; 32], + outcome: CachedOutcome, + target_duration: Duration, + expires_at: Instant, +} + +struct AuthCache { + entries: Vec, + capacity: usize, + ttl: Duration, +} + +impl AuthCache { + fn new(capacity: usize, ttl: Duration) -> Self { + Self { + entries: Vec::with_capacity(capacity), + capacity, + ttl, + } + } + + fn get(&mut self, tag: &[u8; 32]) -> Option<(CachedOutcome, Duration)> { + let now = Instant::now(); + self.entries.retain(|entry| entry.expires_at > now); + self.entries + .iter() + .find(|entry| bool::from(entry.tag.ct_eq(tag))) + .map(|entry| (entry.outcome, entry.target_duration)) + } + + fn insert(&mut self, tag: [u8; 32], outcome: CachedOutcome, target_duration: Duration) { + if self.capacity == 0 { + return; + } + if self.entries.len() == self.capacity { + self.entries.remove(0); + } + self.entries.push(CacheEntry { + tag, + outcome, + target_duration, + expires_at: Instant::now() + self.ttl, + }); + } +} + +impl OpdsBasicAuth { + pub fn disabled() -> Self { + Self { enabled: None } + } + + pub(crate) fn enabled(credentials: OpdsAuthCredentials) -> Result { + Self::enabled_with_settings( + credentials, + DEFAULT_CACHE_CAPACITY, + DEFAULT_CACHE_TTL, + DEFAULT_TARGET_DURATION, + ) + } + + fn enabled_with_settings( + credentials: OpdsAuthCredentials, + cache_capacity: usize, + cache_ttl: Duration, + target_duration: Duration, + ) -> Result { + let password_hash = + PasswordHash::new(&credentials.verifier).map_err(|_| OpdsAuthError::InvalidVerifier)?; + if password_hash.algorithm.as_str() != "argon2id" { + return Err(OpdsAuthError::InvalidVerifier); + } + + let mut cache_key = [0; 32]; + OsRng.fill_bytes(&mut cache_key); + Ok(Self { + enabled: Some(Arc::new(EnabledAuth { + credentials, + cache_key, + cache: Mutex::new(AuthCache::new(cache_capacity, cache_ttl)), + target_duration: Mutex::new(target_duration), + })), + }) + } + + fn is_enabled(&self) -> bool { + self.enabled.is_some() + } + + /// Authorizes complete Authorization header bytes. Disabled authentication does not parse them. + async fn authorize(&self, authorization: Option<&[u8]>) -> bool { + let Some(enabled) = &self.enabled else { + return true; + }; + + let started_at = Instant::now(); + let authorization = authorization.unwrap_or_default(); + let tag = opaque_tag(&enabled.cache_key, authorization); + if let Some((outcome, cached_duration)) = enabled + .cache + .lock() + .ok() + .and_then(|mut cache| cache.get(&tag)) + { + let target_duration = current_target_duration(enabled).max(cached_duration); + pad_to_target(started_at, target_duration).await; + return matches!(outcome, CachedOutcome::Authorized); + } + + let parsed = parse_basic_credentials(authorization); + let authorized = match parsed { + Some(credentials) if credentials.username == enabled.credentials.username => { + let verifier = enabled.credentials.verifier.clone(); + tokio::task::spawn_blocking(move || { + verify_password(&verifier, &credentials.password) + }) + .await + .unwrap_or(false) + } + _ => false, + }; + let target_duration = target_duration(enabled, started_at.elapsed()); + let outcome = if authorized { + CachedOutcome::Authorized + } else { + CachedOutcome::Rejected + }; + if let Ok(mut cache) = enabled.cache.lock() { + cache.insert(tag, outcome, target_duration); + } + pad_to_target(started_at, target_duration).await; + authorized + } +} + +/// Axum 0.8 middleware. Apply it as the final layer around the OPDS router. +pub(crate) async fn require_basic_auth( + State(auth): State, + request: Request, + next: Next, +) -> Response { + if !auth.is_enabled() { + return next.run(request).await; + } + + let authorization = request + .headers() + .get(AUTHORIZATION) + .map(|value| value.as_bytes().to_vec()); + if auth.authorize(authorization.as_deref()).await { + next.run(request).await + } else { + unauthorized_response() + } +} + +fn current_target_duration(enabled: &EnabledAuth) -> Duration { + enabled + .target_duration + .lock() + .map(|duration| *duration) + .unwrap_or(DEFAULT_TARGET_DURATION) +} + +struct BasicCredentials { + username: String, + password: Vec, +} + +fn parse_basic_credentials(authorization: &[u8]) -> Option { + let authorization = std::str::from_utf8(authorization).ok()?; + let (scheme, encoded) = authorization.split_once(' ')?; + if !scheme.eq_ignore_ascii_case("basic") || encoded.is_empty() || encoded.contains(' ') { + return None; + } + let decoded = STANDARD.decode(encoded).ok()?; + let separator = decoded.iter().position(|byte| *byte == b':')?; + let username = std::str::from_utf8(&decoded[..separator]).ok()?.to_string(); + Some(BasicCredentials { + username, + password: decoded[separator + 1..].to_vec(), + }) +} + +fn verify_password(verifier: &str, password: &[u8]) -> bool { + let Ok(password_hash) = PasswordHash::new(verifier) else { + return false; + }; + password_hash.algorithm.as_str() == "argon2id" + && argon2id().verify_password(password, &password_hash).is_ok() +} + +fn argon2id() -> Argon2<'static> { + Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::default()) +} + +fn opaque_tag(cache_key: &[u8; 32], authorization: &[u8]) -> [u8; 32] { + let mut mac = Hmac::::new_from_slice(cache_key).expect("HMAC accepts a fixed-size key"); + mac.update(authorization); + mac.finalize().into_bytes().into() +} + +fn target_duration(enabled: &EnabledAuth, elapsed: Duration) -> Duration { + let Ok(mut target_duration) = enabled.target_duration.lock() else { + return elapsed.max(DEFAULT_TARGET_DURATION); + }; + *target_duration = (*target_duration).max(elapsed); + *target_duration +} + +async fn pad_to_target(started_at: Instant, target_duration: Duration) { + if let Some(remaining) = target_duration.checked_sub(started_at.elapsed()) { + tokio::time::sleep(remaining).await; + } +} + +fn unauthorized_response() -> Response { + ( + StatusCode::UNAUTHORIZED, + [(WWW_AUTHENTICATE, HeaderValue::from_static(BASIC_CHALLENGE))], + ) + .into_response() +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{header::AUTHORIZATION, Request}, + middleware, + routing::get, + Router, + }; + use tower::ServiceExt; + + use super::*; + + fn basic_header(username: &str, password: &[u8]) -> Vec { + let mut credentials = username.as_bytes().to_vec(); + credentials.push(b':'); + credentials.extend(password); + format!("Basic {}", STANDARD.encode(credentials)).into_bytes() + } + + fn enabled_auth(username: &str, password: &[u8], target_duration: Duration) -> OpdsBasicAuth { + OpdsBasicAuth::enabled_with_settings( + create_credentials(username.to_string(), password).unwrap(), + 2, + Duration::from_secs(1), + target_duration, + ) + .unwrap() + } + + fn app(auth: OpdsBasicAuth) -> Router { + Router::new() + .route("/opds", get(|| async { "catalog" })) + .layer(middleware::from_fn_with_state(auth, require_basic_auth)) + } + + async fn response(auth: OpdsBasicAuth, authorization: Option>) -> Response { + let mut request = Request::builder().uri("/opds"); + if let Some(authorization) = authorization { + request = request.header(AUTHORIZATION, authorization); + } + app(auth) + .oneshot(request.body(Body::empty()).unwrap()) + .await + .unwrap() + } + + #[test] + fn credentials_store_a_random_argon2id_phc_verifier() { + let first = create_credentials("reader".to_string(), b"correct horse").unwrap(); + let second = create_credentials("reader".to_string(), b"correct horse").unwrap(); + + assert_eq!(first.username, "reader"); + assert!(first.verifier.starts_with("$argon2id$")); + assert_ne!(first.verifier, second.verifier); + let password_hash = PasswordHash::new(&first.verifier).unwrap(); + assert!(argon2id() + .verify_password(b"correct horse", &password_hash) + .is_ok()); + } + + #[tokio::test] + async fn disabled_auth_bypasses_even_malformed_authorization() { + let auth = OpdsBasicAuth::disabled(); + + assert!(auth.authorize(Some(b"not even close to Basic")).await); + assert_eq!( + response(auth, Some(b"not even close to Basic".to_vec())) + .await + .status(), + StatusCode::OK + ); + } + + #[tokio::test] + async fn enabled_auth_accepts_only_the_configured_basic_credentials() { + let auth = enabled_auth("reader", b"correct horse", Duration::ZERO); + + assert!( + auth.authorize(Some(&basic_header("reader", b"correct horse"))) + .await + ); + assert!( + !auth + .authorize(Some(&basic_header("reader", b"wrong password"))) + .await + ); + assert!( + !auth + .authorize(Some(&basic_header("someone-else", b"correct horse"))) + .await + ); + assert!(!auth.authorize(None).await); + } + + #[tokio::test] + async fn middleware_challenges_missing_and_wrong_credentials_and_allows_correct_ones() { + let auth = enabled_auth("reader", b"correct horse", Duration::ZERO); + + let missing = response(auth.clone(), None).await; + let wrong = response( + auth.clone(), + Some(basic_header("reader", b"wrong password")), + ) + .await; + let correct = response(auth, Some(basic_header("reader", b"correct horse"))).await; + + for rejected in [missing, wrong] { + assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + rejected.headers().get(WWW_AUTHENTICATE).unwrap(), + BASIC_CHALLENGE + ); + } + assert_eq!(correct.status(), StatusCode::OK); + } + + #[tokio::test] + async fn cache_records_and_reuses_the_authorization_outcome_without_raw_header_bytes() { + let auth = enabled_auth("reader", b"correct horse", Duration::ZERO); + let header = basic_header("reader", b"correct horse"); + + assert!(auth.authorize(Some(&header)).await); + let enabled = auth.enabled.as_ref().unwrap(); + let cache = enabled.cache.lock().unwrap(); + assert_eq!(cache.entries.len(), 1); + assert_eq!( + cache.entries[0].tag, + opaque_tag(&enabled.cache_key, &header) + ); + assert!(matches!( + cache.entries[0].outcome, + CachedOutcome::Authorized + )); + drop(cache); + + assert!(auth.authorize(Some(&header)).await); + assert_eq!(enabled.cache.lock().unwrap().entries.len(), 1); + } + + #[tokio::test] + async fn unknown_usernames_and_cached_rejections_are_padded_to_the_target_duration() { + let target_duration = Duration::from_millis(20); + let auth = enabled_auth("reader", b"correct horse", target_duration); + let unknown_header = basic_header("unknown", b"correct horse"); + + let first_started_at = Instant::now(); + assert!(!auth.authorize(Some(&unknown_header)).await); + let first_elapsed = first_started_at.elapsed(); + + let cached_started_at = Instant::now(); + assert!(!auth.authorize(Some(&unknown_header)).await); + let cached_elapsed = cached_started_at.elapsed(); + + let minimum_padded_duration = target_duration - Duration::from_millis(2); + assert!(first_elapsed >= minimum_padded_duration); + assert!(cached_elapsed >= minimum_padded_duration); + } + + #[test] + fn basic_parser_preserves_password_bytes_after_the_first_colon() { + let header = basic_header("reader", b"pa:ss:word"); + let credentials = parse_basic_credentials(&header).unwrap(); + + assert_eq!(credentials.username, "reader"); + assert_eq!(credentials.password, b"pa:ss:word"); + } + + #[test] + fn cache_uses_only_opaque_complete_header_tags_and_is_bounded() { + let key = [7; 32]; + let first = opaque_tag(&key, b"Basic cmVhZGVyOmZpcnN0"); + let second = opaque_tag(&key, b"Basic cmVhZGVyOnNlY29uZA=="); + let differently_cased = opaque_tag(&key, b"basic cmVhZGVyOmZpcnN0"); + assert_ne!(first, second); + assert_ne!(first, differently_cased); + + let mut cache = AuthCache::new(1, Duration::from_secs(1)); + cache.insert(first, CachedOutcome::Authorized, Duration::from_millis(10)); + cache.insert(second, CachedOutcome::Rejected, Duration::from_millis(20)); + + assert!(cache.get(&first).is_none()); + assert_eq!( + cache + .get(&second) + .map(|(_, target_duration)| target_duration), + Some(Duration::from_millis(20)) + ); + } + + #[test] + fn unauthorized_response_uses_the_koreader_basic_challenge() { + let response = unauthorized_response(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + response.headers().get(WWW_AUTHENTICATE).unwrap(), + "Basic realm=\"Citadel\"" + ); + } +} diff --git a/crates/citadel-opds/src/catalog.rs b/crates/citadel-opds/src/catalog.rs new file mode 100644 index 00000000..e4d0fc8c --- /dev/null +++ b/crates/citadel-opds/src/catalog.rs @@ -0,0 +1,1856 @@ +use std::{borrow::Cow, io::Read, sync::Arc}; + +use axum::{ + body::Body, + extract::{Path, Query, State}, + http::{header, HeaderValue, StatusCode}, + response::{IntoResponse, Response}, + routing::get, + Router, +}; +use bytes::Bytes; +use chrono::{NaiveDateTime, SecondsFormat}; +use libcalibre::{ + AuthorId, BookId, BookPage, BookQuery, BookSortOrder, CalibreError, ResolvedBookAsset, +}; +use quick_xml::{ + events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event}, + Writer, +}; +use serde::Deserialize; + +use super::{ + assets::{self, AssetMethod}, + auth::{require_basic_auth, OpdsBasicAuth}, +}; + +const PAGE_SIZE: i64 = 50; +const MAX_SEARCH_LENGTH: usize = 200; +const ACQUISITION_REL: &str = "http://opds-spec.org/acquisition"; +const IMAGE_REL: &str = "http://opds-spec.org/image"; +const ATOM_TYPE: &str = "application/atom+xml;profile=opds-catalog;kind=acquisition"; +const ATOM_CONTENT_TYPE: &str = + "application/atom+xml;profile=opds-catalog;kind=acquisition; charset=utf-8"; +const NAVIGATION_TYPE: &str = "application/atom+xml;profile=opds-catalog;kind=navigation"; +const NAVIGATION_CONTENT_TYPE: &str = + "application/atom+xml;profile=opds-catalog;kind=navigation; charset=utf-8"; +const OPENSEARCH_TYPE: &str = "application/opensearchdescription+xml"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CatalogFilter { + All, + Unread, + Author(i32), + Series(i32), + Tag(i32), + Genre(i32), + Search(String), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CatalogSort { + Title, + Updated, + SeriesIndex, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CatalogBookQuery { + pub filter: CatalogFilter, + pub sort: CatalogSort, + pub limit: i64, + pub offset: i64, +} + +impl CatalogBookQuery { + /// Translate the protocol query into libcalibre's bounded query contract. + /// Genre becomes available when CDL-30 adds its canonical library filter. + pub fn into_calibre(self) -> Option { + let mut query = BookQuery { + limit: Some(self.limit), + offset: self.offset, + sort: match self.sort { + CatalogSort::Title => BookSortOrder::TitleAsc, + CatalogSort::Updated => BookSortOrder::UpdatedDesc, + CatalogSort::SeriesIndex => BookSortOrder::SeriesIndexAsc, + }, + ..BookQuery::default() + }; + match self.filter { + CatalogFilter::All => {} + CatalogFilter::Unread => query.hide_read = true, + CatalogFilter::Author(id) => query.author_id = Some(AuthorId(id)), + CatalogFilter::Series(id) => query.series_id = Some(id), + CatalogFilter::Tag(id) => query.tag_id = Some(id), + CatalogFilter::Genre(_) => return None, + CatalogFilter::Search(text) => query.text = Some(text), + } + Some(query) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CatalogFacet { + pub id: i32, + pub title: String, + pub book_count: Option, +} + +pub trait CatalogSource: Send + Sync + 'static { + fn active_library_id(&self) -> Result; + + fn book_page( + &self, + query: CatalogBookQuery, + ) -> Result<(String, Option, BookPage), CalibreError>; + fn authors(&self) -> Result, CalibreError> { + Ok(Vec::new()) + } + fn series(&self) -> Result, CalibreError> { + Ok(Vec::new()) + } + fn tags(&self) -> Result, CalibreError> { + Ok(Vec::new()) + } + fn genres(&self) -> Result, CalibreError> { + Ok(Vec::new()) + } + fn book_file(&self, book_id: BookId, format: &str) -> Result; + fn book_cover(&self, book_id: BookId) -> Result; +} + +#[derive(Clone)] +struct CatalogState { + source: Arc, +} + +#[derive(Deserialize)] +struct PageQuery { + page: Option, +} + +pub fn router(source: Arc, auth: OpdsBasicAuth) -> Router { + Router::new() + .route("/opds", get(root_feed)) + .route("/opds/all", get(all_books_feed)) + .route("/opds/recent", get(recent_books_feed)) + .route("/opds/unread", get(unread_books_feed)) + .route("/opds/authors", get(authors_feed)) + .route("/opds/authors/{id}", get(author_books_feed)) + .route("/opds/series", get(series_feed)) + .route("/opds/series/{id}", get(series_books_feed)) + .route("/opds/tags", get(tags_feed)) + .route("/opds/tags/{id}", get(tag_books_feed)) + .route("/opds/genres", get(genres_feed)) + .route("/opds/genres/{id}", get(genre_books_feed)) + .route("/opds/search", get(search_feed)) + .route("/opds/opensearch.xml", get(opensearch_description)) + .route( + "/opds/books/{book_id}/files/{format}/{filename}", + get(book_file).head(book_file_head), + ) + .route( + "/opds/books/{book_id}/cover", + get(book_cover).head(book_cover_head), + ) + .with_state(CatalogState { source }) + .layer(axum::middleware::from_fn_with_state( + auth, + require_basic_auth, + )) +} + +async fn root_feed(state: State, query: Query) -> Response { + if query.page.unwrap_or(1) != 1 { + return public_error(StatusCode::NOT_FOUND, "Page not found"); + } + let source = state.source.clone(); + let library_uuid = match tokio::task::spawn_blocking(move || source.active_library_id()).await { + Ok(Ok(uuid)) => uuid, + Ok(Err(error)) => return calibre_error(error), + Err(_) => return public_error(StatusCode::INTERNAL_SERVER_ERROR, "Catalog unavailable"), + }; + match root_navigation_feed(&library_uuid) { + Ok(xml) => xml_response(NAVIGATION_CONTENT_TYPE, xml), + Err(_) => public_error(StatusCode::INTERNAL_SERVER_ERROR, "Catalog unavailable"), + } +} + +async fn all_books_feed(state: State, query: Query) -> Response { + acquisition_handler( + state, + query.0, + CatalogFilter::All, + CatalogSort::Title, + "/opds/all", + "All Books", + ) + .await +} + +async fn recent_books_feed(state: State, query: Query) -> Response { + acquisition_handler( + state, + query.0, + CatalogFilter::All, + CatalogSort::Updated, + "/opds/recent", + "Recently Modified", + ) + .await +} + +async fn unread_books_feed(state: State, query: Query) -> Response { + acquisition_handler( + state, + query.0, + CatalogFilter::Unread, + CatalogSort::Title, + "/opds/unread", + "Unread", + ) + .await +} + +async fn author_books_feed( + state: State, + Path(id): Path, + query: Query, +) -> Response { + acquisition_handler( + state, + query.0, + CatalogFilter::Author(id), + CatalogSort::Title, + &format!("/opds/authors/{id}"), + "Books by Author", + ) + .await +} + +async fn series_books_feed( + state: State, + Path(id): Path, + query: Query, +) -> Response { + acquisition_handler( + state, + query.0, + CatalogFilter::Series(id), + CatalogSort::SeriesIndex, + &format!("/opds/series/{id}"), + "Books in Series", + ) + .await +} + +async fn tag_books_feed( + state: State, + Path(id): Path, + query: Query, +) -> Response { + acquisition_handler( + state, + query.0, + CatalogFilter::Tag(id), + CatalogSort::Title, + &format!("/opds/tags/{id}"), + "Books by Tag", + ) + .await +} + +async fn genre_books_feed( + state: State, + Path(id): Path, + query: Query, +) -> Response { + acquisition_handler( + state, + query.0, + CatalogFilter::Genre(id), + CatalogSort::Title, + &format!("/opds/genres/{id}"), + "Books by Genre", + ) + .await +} + +#[derive(Deserialize)] +struct SearchQuery { + q: Option, + page: Option, +} + +async fn search_feed(state: State, Query(query): Query) -> Response { + let text = query.q.unwrap_or_default(); + let text = text.trim(); + if text.is_empty() { + return public_error(StatusCode::BAD_REQUEST, "Search query is required"); + } + if text.chars().count() > MAX_SEARCH_LENGTH { + return public_error(StatusCode::BAD_REQUEST, "Search query is too long"); + } + let route = format!("/opds/search?q={}", urlencoding::encode(text)); + acquisition_handler( + state, + PageQuery { page: query.page }, + CatalogFilter::Search(text.to_string()), + CatalogSort::Title, + &route, + "Search Results", + ) + .await +} + +async fn authors_feed(state: State) -> Response { + facet_handler(state, CatalogFacetKind::Authors).await +} + +async fn series_feed(state: State) -> Response { + facet_handler(state, CatalogFacetKind::Series).await +} + +async fn tags_feed(state: State) -> Response { + facet_handler(state, CatalogFacetKind::Tags).await +} + +async fn genres_feed(state: State) -> Response { + facet_handler(state, CatalogFacetKind::Genres).await +} + +async fn opensearch_description() -> Response { + xml_response(OPENSEARCH_TYPE, opensearch_xml()) +} + +async fn acquisition_handler( + State(state): State, + query: PageQuery, + filter: CatalogFilter, + sort: CatalogSort, + route: &str, + title: &str, +) -> Response { + let page_number = query.page.unwrap_or(1); + if page_number == 0 { + return public_error(StatusCode::BAD_REQUEST, "Invalid page"); + } + let offset = match page_number + .checked_sub(1) + .and_then(|page| page.checked_mul(PAGE_SIZE as u64)) + .and_then(|offset| i64::try_from(offset).ok()) + { + Some(offset) => offset, + None => return public_error(StatusCode::BAD_REQUEST, "Invalid page"), + }; + + let source = state.source.clone(); + let result = tokio::task::spawn_blocking(move || { + source.book_page(CatalogBookQuery { + filter, + sort, + limit: PAGE_SIZE, + offset, + }) + }) + .await; + let (library_uuid, updated_at, page) = match result { + Ok(Ok(page)) => page, + Ok(Err(error)) => return calibre_error(error), + Err(_) => return public_error(StatusCode::INTERNAL_SERVER_ERROR, "Catalog unavailable"), + }; + + let last_page = page_count(page.total); + if page_number > last_page { + return public_error(StatusCode::NOT_FOUND, "Page not found"); + } + + match acquisition_feed( + &library_uuid, + updated_at, + &page, + page_number, + last_page, + route, + title, + ) { + Ok(xml) => ( + [( + header::CONTENT_TYPE, + HeaderValue::from_static(ATOM_CONTENT_TYPE), + )], + xml, + ) + .into_response(), + Err(_) => public_error(StatusCode::INTERNAL_SERVER_ERROR, "Catalog unavailable"), + } +} + +#[derive(Clone, Copy)] +enum CatalogFacetKind { + Authors, + Series, + Tags, + Genres, +} + +impl CatalogFacetKind { + fn title(self) -> &'static str { + match self { + Self::Authors => "Authors", + Self::Series => "Series", + Self::Tags => "Tags", + Self::Genres => "Genres", + } + } + + fn route(self) -> &'static str { + match self { + Self::Authors => "/opds/authors", + Self::Series => "/opds/series", + Self::Tags => "/opds/tags", + Self::Genres => "/opds/genres", + } + } +} + +async fn facet_handler(State(state): State, kind: CatalogFacetKind) -> Response { + let source = state.source.clone(); + let result = tokio::task::spawn_blocking(move || { + let library_uuid = source.active_library_id()?; + let facets = match kind { + CatalogFacetKind::Authors => source.authors()?, + CatalogFacetKind::Series => source.series()?, + CatalogFacetKind::Tags => source.tags()?, + CatalogFacetKind::Genres => source.genres()?, + }; + Ok::<_, CalibreError>((library_uuid, facets)) + }) + .await; + let (library_uuid, facets) = match result { + Ok(Ok(result)) => result, + Ok(Err(error)) => return calibre_error(error), + Err(_) => return public_error(StatusCode::INTERNAL_SERVER_ERROR, "Catalog unavailable"), + }; + match facet_navigation_feed(&library_uuid, kind, &facets) { + Ok(xml) => xml_response(NAVIGATION_CONTENT_TYPE, xml), + Err(_) => public_error(StatusCode::INTERNAL_SERVER_ERROR, "Catalog unavailable"), + } +} + +fn xml_response(content_type: &'static str, xml: Vec) -> Response { + ([(header::CONTENT_TYPE, content_type)], xml).into_response() +} + +async fn book_file( + state: State, + Path((book_id, format, _filename)): Path<(i32, String, String)>, + headers: axum::http::HeaderMap, +) -> Response { + asset_response( + state, + BookId(book_id), + Some(format), + AssetMethod::Get, + headers, + ) + .await +} + +async fn book_file_head( + state: State, + Path((book_id, format, _filename)): Path<(i32, String, String)>, + headers: axum::http::HeaderMap, +) -> Response { + asset_response( + state, + BookId(book_id), + Some(format), + AssetMethod::Head, + headers, + ) + .await +} + +async fn book_cover( + state: State, + Path(book_id): Path, + headers: axum::http::HeaderMap, +) -> Response { + asset_response(state, BookId(book_id), None, AssetMethod::Get, headers).await +} + +async fn book_cover_head( + state: State, + Path(book_id): Path, + headers: axum::http::HeaderMap, +) -> Response { + asset_response(state, BookId(book_id), None, AssetMethod::Head, headers).await +} + +async fn asset_response( + State(state): State, + book_id: BookId, + format: Option, + method: AssetMethod, + headers: axum::http::HeaderMap, +) -> Response { + let range = headers + .get(header::RANGE) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let source = state.source.clone(); + let resolved = tokio::task::spawn_blocking(move || match format { + Some(format) => source.book_file(book_id, &format), + None => source.book_cover(book_id), + }) + .await; + let asset = match resolved { + Ok(Ok(asset)) => asset, + Ok(Err(error)) => return calibre_error(error), + Err(_) => return public_error(StatusCode::INTERNAL_SERVER_ERROR, "Asset unavailable"), + }; + + let prepared = + tokio::task::spawn_blocking(move || assets::prepare(&asset, method, range.as_deref())) + .await; + let prepared = match prepared { + Ok(Ok(response)) => response, + Ok(Err(error)) => { + let mut response = public_error( + StatusCode::from_u16(error.status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), + if error.status() == 416 { + "Range not satisfiable" + } else { + "Asset unavailable" + }, + ); + if let Some(content_range) = error.content_range() { + if let Ok(value) = HeaderValue::from_str(&content_range) { + response.headers_mut().insert(header::CONTENT_RANGE, value); + } + } + return response; + } + Err(_) => return public_error(StatusCode::INTERNAL_SERVER_ERROR, "Asset unavailable"), + }; + + let mut builder = Response::builder() + .status(prepared.status) + .header(header::ACCEPT_RANGES, prepared.headers.accept_ranges) + .header( + header::CONTENT_DISPOSITION, + prepared.headers.content_disposition, + ) + .header(header::CONTENT_LENGTH, prepared.headers.content_length) + .header(header::CONTENT_TYPE, prepared.headers.content_type); + if let Some(content_range) = prepared.headers.content_range { + builder = builder.header(header::CONTENT_RANGE, content_range); + } + + let body = match prepared.body { + Some(mut reader) => { + let (sender, receiver) = tokio::sync::mpsc::channel(2); + tokio::task::spawn_blocking(move || loop { + let mut buffer = vec![0_u8; 64 * 1024]; + match reader.read(&mut buffer) { + Ok(0) => break, + Ok(read) => { + buffer.truncate(read); + if sender + .blocking_send(Ok::<_, std::io::Error>(Bytes::from(buffer))) + .is_err() + { + break; + } + } + Err(error) => { + let _ = sender.blocking_send(Err(error)); + break; + } + } + }); + Body::from_stream(futures_util::stream::unfold( + receiver, + |mut receiver| async move { receiver.recv().await.map(|item| (item, receiver)) }, + )) + } + None => Body::empty(), + }; + builder + .body(body) + .unwrap_or_else(|_| public_error(StatusCode::INTERNAL_SERVER_ERROR, "Asset unavailable")) +} + +fn acquisition_feed( + library_uuid: &str, + updated_at: Option, + page: &BookPage, + page_number: u64, + last_page: u64, + route: &str, + title: &str, +) -> Result, quick_xml::Error> { + let mut writer = Writer::new(Vec::new()); + writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?; + let mut feed = BytesStart::new("feed"); + feed.push_attribute(("xmlns", "http://www.w3.org/2005/Atom")); + feed.push_attribute(("xmlns:dc", "http://purl.org/dc/terms/")); + writer.write_event(Event::Start(feed))?; + text_element(&mut writer, "id", &library_identity(library_uuid))?; + text_element(&mut writer, "title", &format!("Citadel — {title}"))?; + text_element(&mut writer, "updated", &feed_updated(updated_at))?; + writer.write_event(Event::Start(BytesStart::new("author")))?; + text_element(&mut writer, "name", "Citadel")?; + writer.write_event(Event::End(BytesEnd::new("author")))?; + + let self_href = page_href(route, page_number); + link(&mut writer, "self", &self_href, ATOM_TYPE)?; + link(&mut writer, "start", "/opds", ATOM_TYPE)?; + if route != "/opds" { + link(&mut writer, "up", "/opds", ATOM_TYPE)?; + } + if page_number > 1 { + link( + &mut writer, + "previous", + &page_href(route, page_number - 1), + ATOM_TYPE, + )?; + link(&mut writer, "first", route, ATOM_TYPE)?; + } + if page_number < last_page { + link( + &mut writer, + "next", + &page_href(route, page_number + 1), + ATOM_TYPE, + )?; + link(&mut writer, "last", &page_href(route, last_page), ATOM_TYPE)?; + } + + for book in &page.items { + writer.write_event(Event::Start(BytesStart::new("entry")))?; + text_element( + &mut writer, + "id", + &book_identity(library_uuid, book.uuid.as_deref(), book.id), + )?; + text_element(&mut writer, "title", &book.title)?; + text_element(&mut writer, "updated", ×tamp(book.updated_at))?; + for author in &book.authors { + writer.write_event(Event::Start(BytesStart::new("author")))?; + text_element(&mut writer, "name", &author.name)?; + writer.write_event(Event::End(BytesEnd::new("author")))?; + } + if book.created_at != chrono::DateTime::UNIX_EPOCH.naive_utc() { + text_element(&mut writer, "published", ×tamp(book.created_at))?; + } + if let Some(description) = &book.description { + let mut content = BytesStart::new("content"); + content.push_attribute(("type", "text")); + writer.write_event(Event::Start(content))?; + writer.write_event(Event::Text(BytesText::new(&xml_text(description))))?; + writer.write_event(Event::End(BytesEnd::new("content")))?; + } + for language in &book.language_codes { + text_element(&mut writer, "dc:language", language)?; + } + for identifier in &book.identifiers { + text_element( + &mut writer, + "dc:identifier", + &format!("{}:{}", identifier.label, identifier.value), + )?; + } + for tag in &book.tags { + let mut category = BytesStart::new("category"); + category.push_attribute(("term", xml_text(tag).as_ref())); + writer.write_event(Event::Empty(category))?; + } + if let Some(series) = &book.series { + let mut category = BytesStart::new("category"); + category.push_attribute(("scheme", "http://calibre-ebook.com/series")); + category.push_attribute(("term", xml_text(series).as_ref())); + if let Some(index) = book.series_index { + let label = format!("{series} [{index}]"); + category.push_attribute(("label", xml_text(&label).as_ref())); + } + writer.write_event(Event::Empty(category))?; + } + if book.has_cover { + link( + &mut writer, + IMAGE_REL, + &format!("/opds/books/{}/cover", book.id.as_i32()), + "image/jpeg", + )?; + } + for file in &book.files { + link( + &mut writer, + ACQUISITION_REL, + &format!( + "/opds/books/{}/files/{}/{}", + book.id.as_i32(), + urlencoding::encode(&file.format), + urlencoding::encode(&format!( + "{}.{}", + file.name, + file.format.to_ascii_lowercase() + )) + ), + assets::mime_type(&file.format), + )?; + } + writer.write_event(Event::End(BytesEnd::new("entry")))?; + } + + writer.write_event(Event::End(BytesEnd::new("feed")))?; + Ok(writer.into_inner()) +} + +fn root_navigation_feed(library_uuid: &str) -> Result, quick_xml::Error> { + let entries = [ + ("all", "All Books", "/opds/all", ATOM_TYPE), + ("recent", "Recently Modified", "/opds/recent", ATOM_TYPE), + ("unread", "Unread", "/opds/unread", ATOM_TYPE), + ("authors", "Authors", "/opds/authors", NAVIGATION_TYPE), + ("series", "Series", "/opds/series", NAVIGATION_TYPE), + ("tags", "Tags", "/opds/tags", NAVIGATION_TYPE), + ("genres", "Genres", "/opds/genres", NAVIGATION_TYPE), + ]; + let mut writer = navigation_writer(library_uuid, "Citadel", "/opds")?; + link( + &mut writer, + "search", + "/opds/opensearch.xml", + OPENSEARCH_TYPE, + )?; + for (id, title, href, media_type) in entries { + navigation_entry(&mut writer, library_uuid, id, title, href, media_type, None)?; + } + writer.write_event(Event::End(BytesEnd::new("feed")))?; + Ok(writer.into_inner()) +} + +fn facet_navigation_feed( + library_uuid: &str, + kind: CatalogFacetKind, + facets: &[CatalogFacet], +) -> Result, quick_xml::Error> { + let mut writer = navigation_writer(library_uuid, kind.title(), kind.route())?; + link(&mut writer, "up", "/opds", NAVIGATION_TYPE)?; + for facet in facets { + navigation_entry( + &mut writer, + library_uuid, + &facet.id.to_string(), + &facet.title, + &format!("{}/{}", kind.route(), facet.id), + ATOM_TYPE, + facet.book_count, + )?; + } + writer.write_event(Event::End(BytesEnd::new("feed")))?; + Ok(writer.into_inner()) +} + +fn navigation_writer( + library_uuid: &str, + title: &str, + self_href: &str, +) -> Result>, quick_xml::Error> { + let mut writer = Writer::new(Vec::new()); + writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?; + let mut feed = BytesStart::new("feed"); + feed.push_attribute(("xmlns", "http://www.w3.org/2005/Atom")); + writer.write_event(Event::Start(feed))?; + text_element( + &mut writer, + "id", + &format!( + "{}:navigation:{}", + library_identity(library_uuid), + hex(self_href.as_bytes()) + ), + )?; + text_element(&mut writer, "title", &format!("Citadel — {title}"))?; + text_element(&mut writer, "updated", &feed_updated(None))?; + writer.write_event(Event::Start(BytesStart::new("author")))?; + text_element(&mut writer, "name", "Citadel")?; + writer.write_event(Event::End(BytesEnd::new("author")))?; + link(&mut writer, "self", self_href, NAVIGATION_TYPE)?; + link(&mut writer, "start", "/opds", NAVIGATION_TYPE)?; + Ok(writer) +} + +fn navigation_entry( + writer: &mut Writer>, + library_uuid: &str, + id: &str, + title: &str, + href: &str, + media_type: &str, + count: Option, +) -> Result<(), quick_xml::Error> { + writer.write_event(Event::Start(BytesStart::new("entry")))?; + text_element( + writer, + "id", + &format!( + "{}:navigation:{}", + library_identity(library_uuid), + hex(id.as_bytes()) + ), + )?; + text_element(writer, "title", title)?; + text_element(writer, "updated", &feed_updated(None))?; + if let Some(count) = count { + let mut content = BytesStart::new("content"); + content.push_attribute(("type", "text")); + writer.write_event(Event::Start(content))?; + writer.write_event(Event::Text(BytesText::new(&format!("{count} books"))))?; + writer.write_event(Event::End(BytesEnd::new("content")))?; + } + link(writer, "subsection", href, media_type)?; + writer.write_event(Event::End(BytesEnd::new("entry")))?; + Ok(()) +} + +fn opensearch_xml() -> Vec { + br#" + + Citadel + Search the active Citadel library + UTF-8 + UTF-8 + +"# + .to_vec() +} + +fn text_element( + writer: &mut Writer>, + name: &str, + value: &str, +) -> Result<(), quick_xml::Error> { + writer.write_event(Event::Start(BytesStart::new(name)))?; + writer.write_event(Event::Text(BytesText::new(&xml_text(value))))?; + writer.write_event(Event::End(BytesEnd::new(name)))?; + Ok(()) +} + +fn link( + writer: &mut Writer>, + relation: &str, + href: &str, + media_type: &str, +) -> Result<(), quick_xml::Error> { + let mut element = BytesStart::new("link"); + element.push_attribute(("rel", relation)); + element.push_attribute(("href", href)); + element.push_attribute(("type", media_type)); + writer.write_event(Event::Empty(element))?; + Ok(()) +} + +fn page_count(total: i64) -> u64 { + if total <= 0 { + 1 + } else { + ((total as u64 - 1) / PAGE_SIZE as u64) + 1 + } +} + +fn page_href(route: &str, page: u64) -> String { + if page == 1 { + route.to_string() + } else if route.contains('?') { + format!("{route}&page={page}") + } else { + format!("{route}?page={page}") + } +} + +fn library_identity(raw_uuid: &str) -> String { + match uuid::Uuid::parse_str(raw_uuid) { + Ok(uuid) => format!("urn:uuid:{uuid}"), + Err(_) => format!("urn:citadel:library:{}", hex(raw_uuid.as_bytes())), + } +} + +fn book_identity(library_uuid: &str, raw_uuid: Option<&str>, book_id: BookId) -> String { + match raw_uuid.and_then(|value| uuid::Uuid::parse_str(value).ok()) { + Some(uuid) => format!("urn:uuid:{uuid}"), + None => format!( + "urn:citadel:book:{}:{}", + hex(library_uuid.as_bytes()), + book_id.as_i32() + ), + } +} + +fn hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut result = String::with_capacity(bytes.len() * 2); + for byte in bytes { + result.push(char::from(HEX[usize::from(byte >> 4)])); + result.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + result +} + +fn xml_text(value: &str) -> Cow<'_, str> { + if value.chars().all(valid_xml_char) { + Cow::Borrowed(value) + } else { + Cow::Owned( + value + .chars() + .filter(|character| valid_xml_char(*character)) + .collect(), + ) + } +} + +fn valid_xml_char(character: char) -> bool { + matches!(character, '\u{9}' | '\u{a}' | '\u{d}') + || ('\u{20}'..='\u{d7ff}').contains(&character) + || ('\u{e000}'..='\u{fffd}').contains(&character) + || ('\u{10000}'..='\u{10ffff}').contains(&character) +} + +fn feed_updated(updated_at: Option) -> String { + updated_at + .map(timestamp) + .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string()) +} + +fn timestamp(timestamp: NaiveDateTime) -> String { + timestamp + .and_utc() + .to_rfc3339_opts(SecondsFormat::AutoSi, true) +} + +fn calibre_error(error: CalibreError) -> Response { + match error { + CalibreError::LibraryNotInitialized => { + public_error(StatusCode::SERVICE_UNAVAILABLE, "No active library") + } + CalibreError::BookNotFound(_) + | CalibreError::BookFileNotFound(_, _) + | CalibreError::BookCoverNotFound(_) + | CalibreError::AssetFileMissing(_) => { + public_error(StatusCode::NOT_FOUND, "Asset not found") + } + CalibreError::InvalidBookFormat(_) => { + public_error(StatusCode::BAD_REQUEST, "Invalid asset request") + } + CalibreError::Database(message) + if message.to_ascii_lowercase().contains("busy") + || message.to_ascii_lowercase().contains("locked") => + { + public_error(StatusCode::SERVICE_UNAVAILABLE, "Library busy") + } + _ => public_error(StatusCode::INTERNAL_SERVER_ERROR, "Catalog unavailable"), + } +} + +fn public_error(status: StatusCode, message: &'static str) -> Response { + ( + status, + [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], + message, + ) + .into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDate; + use diesel::{Connection, RunQueryDsl}; + use libcalibre::{ + library::Book, util::get_db_path, BookAdd, BookFileInfo, BookIdentifier, BookUpdate, + Library, LibraryAuthor, + }; + use quick_xml::{name::ResolveResult, NsReader}; + use std::{collections::HashMap, path::PathBuf, sync::Mutex}; + use tempfile::TempDir; + + fn book(title: &str, uuid: &str, id: i32) -> Book { + Book { + id: BookId(id), + uuid: Some(uuid.to_string()), + title: title.to_string(), + sortable_title: None, + authors: vec![LibraryAuthor { + id: libcalibre::AuthorId(1), + name: "A & Co".to_string(), + sort: "Writer, A".to_string(), + link: None, + }], + tags: Vec::new(), + series: None, + series_index: None, + description: Some("Words & symbols".to_string()), + language_codes: vec!["eng".to_string()], + identifiers: Vec::new(), + has_cover: true, + is_read: false, + files: vec![BookFileInfo { + id: 1, + format: "EPUB".to_string(), + name: "book".to_string(), + uncompressed_size: 3, + }], + created_at: NaiveDate::from_ymd_opt(2024, 1, 2) + .unwrap() + .and_hms_opt(3, 4, 5) + .unwrap(), + updated_at: NaiveDate::from_ymd_opt(2024, 2, 3) + .unwrap() + .and_hms_opt(4, 5, 6) + .unwrap(), + book_dir_path: "Author/Book".to_string(), + } + } + + struct MemorySource { + books: Vec, + } + + impl CatalogSource for MemorySource { + fn active_library_id(&self) -> Result { + self.books + .first() + .map(|_| "memory-library".to_string()) + .ok_or(CalibreError::LibraryNotInitialized) + } + + fn book_page( + &self, + query: CatalogBookQuery, + ) -> Result<(String, Option, BookPage), CalibreError> { + let items = self + .books + .iter() + .skip(query.offset as usize) + .take(query.limit as usize) + .cloned() + .collect(); + Ok(( + "550e8400-e29b-41d4-a716-446655440000".to_string(), + self.books.iter().map(|book| book.updated_at).max(), + BookPage { + items, + total: self.books.len() as i64, + }, + )) + } + + fn book_file( + &self, + book_id: BookId, + format: &str, + ) -> Result { + Err(CalibreError::BookFileNotFound(book_id, format.to_string())) + } + + fn book_cover(&self, book_id: BookId) -> Result { + Err(CalibreError::BookCoverNotFound(book_id)) + } + } + + struct LibrarySource { + library: Mutex, + } + + struct NoLibrary; + + struct FailureSource(fn() -> CalibreError); + + impl CatalogSource for NoLibrary { + fn active_library_id(&self) -> Result { + Err(CalibreError::LibraryNotInitialized) + } + + fn book_page( + &self, + _query: CatalogBookQuery, + ) -> Result<(String, Option, BookPage), CalibreError> { + Err(CalibreError::LibraryNotInitialized) + } + + fn book_file( + &self, + _book_id: BookId, + _format: &str, + ) -> Result { + Err(CalibreError::LibraryNotInitialized) + } + + fn book_cover(&self, _book_id: BookId) -> Result { + Err(CalibreError::LibraryNotInitialized) + } + } + + impl CatalogSource for FailureSource { + fn active_library_id(&self) -> Result { + Err((self.0)()) + } + + fn book_page( + &self, + _query: CatalogBookQuery, + ) -> Result<(String, Option, BookPage), CalibreError> { + Err((self.0)()) + } + + fn book_file( + &self, + _book_id: BookId, + _format: &str, + ) -> Result { + Err((self.0)()) + } + + fn book_cover(&self, _book_id: BookId) -> Result { + Err((self.0)()) + } + } + + impl CatalogSource for LibrarySource { + fn active_library_id(&self) -> Result { + self.library.lock().unwrap().library_uuid() + } + + fn book_page( + &self, + query: CatalogBookQuery, + ) -> Result<(String, Option, BookPage), CalibreError> { + let mut library = self.library.lock().unwrap(); + let uuid = library.library_uuid()?; + let updated = library.catalog_updated_at()?; + let page = match query.into_calibre() { + Some(query) => library.query_acquirable_books_with(query)?, + None => BookPage { + items: Vec::new(), + total: 0, + }, + }; + Ok((uuid, updated, page)) + } + + fn authors(&self) -> Result, CalibreError> { + self.library.lock().unwrap().list_authors().map(|items| { + items + .into_iter() + .map(|item| CatalogFacet { + id: item.id.as_i32(), + title: item.name, + book_count: Some(item.book_count), + }) + .collect() + }) + } + + fn series(&self) -> Result, CalibreError> { + self.library.lock().unwrap().list_series().map(|items| { + items + .into_iter() + .map(|item| CatalogFacet { + id: item.id, + title: item.name, + book_count: Some(item.book_count), + }) + .collect() + }) + } + + fn tags(&self) -> Result, CalibreError> { + self.library.lock().unwrap().list_tags().map(|items| { + items + .into_iter() + .map(|item| CatalogFacet { + id: item.id, + title: item.name, + book_count: Some(item.book_count), + }) + .collect() + }) + } + + fn book_file( + &self, + book_id: BookId, + format: &str, + ) -> Result { + self.library + .lock() + .unwrap() + .resolve_book_file(book_id, format) + } + + fn book_cover(&self, book_id: BookId) -> Result { + self.library.lock().unwrap().resolve_book_cover(book_id) + } + } + + fn test_library() -> (TempDir, Library) { + let directory = tempfile::tempdir().unwrap(); + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../libcalibre/tests/fixtures/empty_library/metadata.db"); + std::fs::copy(fixture, directory.path().join("metadata.db")).unwrap(); + let db_path = get_db_path(directory.path().to_str().unwrap()).unwrap(); + (directory, Library::new(db_path).unwrap()) + } + + async fn loopback(source: Arc) -> (String, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let task = tokio::spawn(async move { + axum::serve(listener, router(source, OpdsBasicAuth::disabled())) + .await + .unwrap(); + }); + (format!("http://{address}"), task) + } + + #[derive(Default)] + struct ParsedFeed { + ids: Vec, + titles: Vec, + content: Vec, + acquisition_hrefs: Vec, + image_hrefs: Vec, + subsection_hrefs: Vec, + next: Option, + previous: Option, + } + + fn parsed_feed(xml: &[u8]) -> ParsedFeed { + const ATOM: &[u8] = b"http://www.w3.org/2005/Atom"; + let mut reader = NsReader::from_reader(xml); + reader.config_mut().trim_text(false); + let mut parsed = ParsedFeed::default(); + let mut current = None::<(Vec, String)>; + let mut in_entry = false; + loop { + match reader.read_resolved_event() { + Ok((ResolveResult::Bound(namespace), Event::Start(element))) + if namespace.as_ref() == ATOM => + { + let name = element.local_name().as_ref().to_vec(); + if name == b"entry" { + in_entry = true; + } else if in_entry && matches!(name.as_slice(), b"id" | b"title" | b"content") { + current = Some((name, String::new())); + } + } + Ok((ResolveResult::Bound(namespace), Event::Empty(element))) + if namespace.as_ref() == ATOM && element.local_name().as_ref() == b"link" => + { + let rel = element + .attributes() + .flatten() + .find(|attribute| attribute.key.as_ref() == b"rel") + .and_then(|attribute| attribute.unescape_value().ok()) + .map(|value| value.into_owned()); + let href = element + .attributes() + .flatten() + .find(|attribute| attribute.key.as_ref() == b"href") + .and_then(|attribute| attribute.unescape_value().ok()) + .map(|value| value.into_owned()); + match (rel.as_deref(), href) { + (Some(ACQUISITION_REL), Some(href)) if in_entry => { + parsed.acquisition_hrefs.push(href) + } + (Some(IMAGE_REL), Some(href)) if in_entry => parsed.image_hrefs.push(href), + (Some("subsection"), Some(href)) if in_entry => { + parsed.subsection_hrefs.push(href) + } + (Some("next"), Some(href)) => parsed.next = Some(href), + (Some("previous"), Some(href)) => parsed.previous = Some(href), + _ => {} + } + } + Ok((_, Event::Text(text))) if current.is_some() => { + current + .as_mut() + .unwrap() + .1 + .push_str(&text.decode().unwrap()); + } + Ok((_, Event::GeneralRef(reference))) if current.is_some() => { + let decoded = reference.decode().unwrap(); + let value = match decoded.as_ref() { + "amp" => "&", + "lt" => "<", + "gt" => ">", + "quot" => "\"", + "apos" => "'", + _ => "", + }; + current.as_mut().unwrap().1.push_str(value); + } + Ok((ResolveResult::Bound(namespace), Event::End(element))) + if namespace.as_ref() == ATOM => + { + if element.local_name().as_ref() == b"entry" { + in_entry = false; + } + if let Some((name, value)) = current.take() { + if element.local_name().as_ref() == name.as_slice() { + match name.as_slice() { + b"id" => parsed.ids.push(value), + b"title" => parsed.titles.push(value), + b"content" => parsed.content.push(value), + _ => {} + } + } else { + current = Some((name, value)); + } + } + } + Ok((_, Event::Eof)) => break, + Err(error) => panic!("invalid OPDS XML: {error}"), + _ => {} + } + } + parsed + } + + #[test] + fn writer_escapes_metadata_and_emits_acquisition_contract() { + let mut legacy_book = book("A \u{1} & More", "not-a-uuid", 42); + legacy_book.authors[0].name = "A \u{0} & Co".to_string(); + legacy_book.description = Some("Words \u{b} & symbols".to_string()); + legacy_book.tags = vec!["Tag \u{c} & two".to_string()]; + legacy_book.identifiers = vec![BookIdentifier { + id: 1, + label: "custom".to_string(), + value: "ID \u{7} & two".to_string(), + }]; + let page = BookPage { + items: vec![legacy_book], + total: 1, + }; + let xml = String::from_utf8( + acquisition_feed("bad-library", None, &page, 1, 1, "/opds", "All Books").unwrap(), + ) + .unwrap(); + assert!(xml.contains("A <Book> & More")); + assert!(xml.contains("A <Writer> & Co")); + assert!(xml.contains("Words <with> & symbols")); + assert!(xml.contains("term=\"Tag <one> & two\"")); + assert!(xml.contains("ID <one> & two")); + assert!(!xml.chars().any(|character| !valid_xml_char(character))); + assert!(xml.contains("urn:citadel:book:6261642d6c696272617279:42")); + assert!(xml.contains("rel=\"http://opds-spec.org/acquisition\"")); + assert!(xml.contains("type=\"application/epub+zip\"")); + assert!(xml.contains("href=\"/opds/books/42/files/EPUB/book.epub\"")); + assert!(xml.contains("rel=\"http://opds-spec.org/image\"")); + let parsed = parsed_feed(xml.as_bytes()); + assert_eq!(parsed.titles, ["A & More"]); + assert_eq!(parsed.content, ["Words & symbols"]); + assert_eq!(parsed.acquisition_hrefs.len(), 1); + } + + #[test] + fn pagination_links_are_bounded() { + let page = BookPage { + items: Vec::new(), + total: 101, + }; + let first = String::from_utf8( + acquisition_feed("id", None, &page, 1, 3, "/opds/all", "All Books").unwrap(), + ) + .unwrap(); + assert!(first.contains("rel=\"next\" href=\"/opds/all?page=2\"")); + assert!(!first.contains("rel=\"previous\"")); + let last = String::from_utf8( + acquisition_feed("id", None, &page, 3, 3, "/opds/all", "All Books").unwrap(), + ) + .unwrap(); + assert!(last.contains("rel=\"previous\" href=\"/opds/all?page=2\"")); + assert!(!last.contains("rel=\"next\"")); + } + + #[test] + fn unavailable_optional_metadata_is_omitted() { + let mut sparse = book("Sparse", "uuid", 1); + sparse.description = None; + sparse.created_at = chrono::DateTime::UNIX_EPOCH.naive_utc(); + sparse.language_codes.clear(); + sparse.identifiers.clear(); + sparse.tags.clear(); + sparse.series = None; + sparse.series_index = None; + let page = BookPage { + items: vec![sparse], + total: 1, + }; + let xml = String::from_utf8( + acquisition_feed("id", None, &page, 1, 1, "/opds/all", "All Books").unwrap(), + ) + .unwrap(); + assert!(!xml.contains("")); + assert!(!xml.contains("")); + assert!(!xml.contains("")); + assert!(!xml.contains(" path = next, + None => break, + } + page_number += 1; + } + ids.sort(); + ids.dedup(); + assert_eq!(ids.len(), 101); + assert_eq!(page_number, 3); + server.abort(); + } + + #[tokio::test] + async fn loopback_serves_real_library_feed_head_get_and_range() { + let (directory, mut library) = test_library(); + let source_path = directory.path().join("L'été & 漢字.epub"); + std::fs::write(&source_path, b"0123456789").unwrap(); + let added = library + .add_book(BookAdd { + title: "Café & More".to_string(), + author_names: vec!["A & Author".to_string()], + tags: Some(vec!["One & Two".to_string()]), + series: None, + series_index: None, + publisher: None, + publication_date: None, + rating: None, + comments: Some("Description & intact".to_string()), + identifiers: HashMap::new(), + language: Some("eng".to_string()), + file_paths: vec![source_path], + }) + .unwrap(); + library + .upsert_book_identifier( + added.id, + "custom".to_string(), + "ID & 漢字".to_string(), + None, + ) + .unwrap(); + let after_identifier = library.get_book(added.id).unwrap().updated_at; + assert!(after_identifier > added.updated_at); + library + .update_book( + added.id, + BookUpdate { + title: None, + author_names: None, + author_ids: None, + description: Some("Description & intact".to_string()), + is_read: None, + tags: None, + series: None, + series_index: None, + language_codes: None, + publisher: None, + publication_date: None, + rating: None, + comments: None, + identifiers: None, + }, + ) + .unwrap(); + let after_metadata = library.get_book(added.id).unwrap().updated_at; + assert!(after_metadata > after_identifier); + library + .set_book_cover(added.id, b"cover-bytes".to_vec()) + .unwrap(); + assert!(library.get_book(added.id).unwrap().updated_at > after_metadata); + let metadata_only = library + .add_book(BookAdd { + title: "Metadata only".to_string(), + author_names: vec!["Nobody".to_string()], + tags: None, + series: None, + series_index: None, + publisher: None, + publication_date: None, + rating: None, + comments: None, + identifiers: HashMap::new(), + language: None, + file_paths: Vec::new(), + }) + .unwrap(); + + let database_path = library.database_path().to_string(); + drop(library); + let mut connection = diesel::SqliteConnection::establish(&database_path).unwrap(); + libcalibre::persistence::register_sql_functions(&mut connection).unwrap(); + diesel::sql_query(format!( + "UPDATE books SET uuid = NULL WHERE id = {}", + added.id.as_i32() + )) + .execute(&mut connection) + .unwrap(); + diesel::sql_query("UPDATE books SET last_modified = '2000-01-01 00:00:00'") + .execute(&mut connection) + .unwrap(); + diesel::sql_query(format!( + "INSERT INTO data (book, format, uncompressed_size, name) \ + VALUES ({}, 'PDF', 10, 'missing')", + metadata_only.id.as_i32() + )) + .execute(&mut connection) + .unwrap(); + drop(connection); + let mut library = + Library::new(get_db_path(directory.path().to_str().unwrap()).unwrap()).unwrap(); + assert_eq!(library.get_book(added.id).unwrap().uuid, None); + assert!( + library.catalog_updated_at().unwrap().unwrap() + > library.get_book(added.id).unwrap().updated_at + ); + + let (base, server) = loopback(Arc::new(LibrarySource { + library: Mutex::new(library), + })) + .await; + let client = reqwest::Client::new(); + let feed_head = client + .head(format!("{base}/opds/all")) + .send() + .await + .unwrap(); + assert_eq!(feed_head.status(), StatusCode::OK); + assert_eq!(feed_head.headers()[header::CONTENT_TYPE], ATOM_CONTENT_TYPE); + assert!(feed_head.headers().get(header::LAST_MODIFIED).is_none()); + assert!(feed_head.bytes().await.unwrap().is_empty()); + let response = client.get(format!("{base}/opds/all")).send().await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let bytes = response.bytes().await.unwrap(); + let xml = String::from_utf8(bytes.to_vec()).unwrap(); + assert!(xml.contains("A & Author")); + assert!(xml.contains("term=\"One & Two\"")); + assert!(xml.contains("ID <one> & 漢字")); + let feed = parsed_feed(&bytes); + assert_eq!(feed.titles, ["Café & More"]); + assert_eq!(feed.content, ["Description & intact"]); + assert!(feed.ids[0].starts_with("urn:citadel:book:")); + assert_eq!(feed.acquisition_hrefs.len(), 1); + let href = &feed.acquisition_hrefs[0]; + assert!(href.ends_with(".epub")); + assert!(href.contains("%")); + + assert_eq!(feed.image_hrefs.len(), 1); + let cover = client + .get(format!("{base}{}", feed.image_hrefs[0])) + .send() + .await + .unwrap(); + assert_eq!(cover.status(), StatusCode::OK); + assert_eq!(cover.headers()[header::CONTENT_TYPE], "image/jpeg"); + assert_eq!(cover.bytes().await.unwrap(), b"cover-bytes".as_slice()); + + let head = client.head(format!("{base}{href}")).send().await.unwrap(); + assert_eq!(head.status(), StatusCode::OK); + assert_eq!(head.headers()[header::CONTENT_TYPE], "application/epub+zip"); + assert_eq!(head.headers()[header::CONTENT_LENGTH], "10"); + assert!(head.bytes().await.unwrap().is_empty()); + + let get = client.get(format!("{base}{href}")).send().await.unwrap(); + assert_eq!(get.bytes().await.unwrap(), b"0123456789".as_slice()); + let range = client + .get(format!("{base}{href}")) + .header(header::RANGE, "bytes=2-5") + .send() + .await + .unwrap(); + assert_eq!(range.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(range.headers()[header::CONTENT_RANGE], "bytes 2-5/10"); + assert_eq!(range.bytes().await.unwrap(), b"2345".as_slice()); + + let missing = client + .get(format!( + "{base}/opds/books/{}/files/PDF/missing.pdf", + added.id.as_i32() + )) + .send() + .await + .unwrap(); + assert_eq!(missing.status(), StatusCode::NOT_FOUND); + assert_eq!(missing.text().await.unwrap(), "Asset not found"); + server.abort(); + } + + #[tokio::test] + async fn real_library_paging_has_no_holes_around_fileless_books() { + let (directory, mut library) = test_library(); + let source_path = directory.path().join("shared.epub"); + std::fs::write(&source_path, b"book").unwrap(); + for index in 0..55 { + let has_file = !matches!(index, 4 | 17 | 33 | 49); + library + .add_book(BookAdd { + title: format!("Book {index:02}"), + author_names: vec!["Author".to_string()], + tags: None, + series: None, + series_index: None, + publisher: None, + publication_date: None, + rating: None, + comments: None, + identifiers: HashMap::new(), + language: None, + file_paths: has_file.then(|| source_path.clone()).into_iter().collect(), + }) + .unwrap(); + } + + let (base, server) = loopback(Arc::new(LibrarySource { + library: Mutex::new(library), + })) + .await; + let client = reqwest::Client::new(); + let first = client.get(format!("{base}/opds/all")).send().await.unwrap(); + assert_eq!(first.status(), StatusCode::OK); + let first = parsed_feed(&first.bytes().await.unwrap()); + assert_eq!(first.ids.len(), 50); + let second_path = first + .next + .expect("51 resolvable books require a second page"); + let second = client + .get(format!("{base}{second_path}")) + .send() + .await + .unwrap(); + let second = parsed_feed(&second.bytes().await.unwrap()); + assert_eq!(second.ids.len(), 1); + assert!(second.next.is_none()); + assert!(second.previous.is_some()); + let mut ids = first.ids; + ids.extend(second.ids); + ids.sort(); + ids.dedup(); + assert_eq!(ids.len(), 51); + server.abort(); + } + + #[tokio::test] + async fn empty_and_unavailable_catalogs_return_valid_non_sensitive_responses() { + let (base, server) = loopback(Arc::new(MemorySource { books: Vec::new() })).await; + let response = reqwest::get(format!("{base}/opds/all")).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let bytes = response.bytes().await.unwrap(); + let feed = parsed_feed(&bytes); + assert!(feed.ids.is_empty()); + assert!(String::from_utf8(bytes.to_vec()) + .unwrap() + .contains("Citadel")); + server.abort(); + + let (base, server) = loopback(Arc::new(NoLibrary)).await; + let response = reqwest::get(format!("{base}/opds")).await.unwrap(); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(response.text().await.unwrap(), "No active library"); + server.abort(); + } + + #[tokio::test] + async fn invalid_pages_and_library_failures_are_bounded_and_redacted() { + let client = reqwest::Client::new(); + let (base, server) = loopback(Arc::new(MemorySource { books: Vec::new() })).await; + for page in ["0", "18446744073709551615"] { + let response = client + .get(format!("{base}/opds/all?page={page}")) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + let response = client + .get(format!("{base}/opds/all?page=2")) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let response = client + .get(format!("{base}/opds/all?page=nope")) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + server.abort(); + + let (base, server) = loopback(Arc::new(FailureSource(|| { + CalibreError::Database("database is locked at /private/library/metadata.db".to_string()) + }))) + .await; + let response = client.get(format!("{base}/opds")).send().await.unwrap(); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(response.text().await.unwrap(), "Library busy"); + server.abort(); + + let (base, server) = loopback(Arc::new(FailureSource(|| { + CalibreError::DatabaseIntegrity("broken /private/library/metadata.db".to_string()) + }))) + .await; + let response = client.get(format!("{base}/opds")).send().await.unwrap(); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(response.text().await.unwrap(), "Catalog unavailable"); + server.abort(); + } +} diff --git a/crates/citadel-opds/src/credentials.rs b/crates/citadel-opds/src/credentials.rs new file mode 100644 index 00000000..a1d17737 --- /dev/null +++ b/crates/citadel-opds/src/credentials.rs @@ -0,0 +1,212 @@ +use std::{ + fs::{self, OpenOptions}, + io::{self, Write}, + path::{Path, PathBuf}, + sync::{Arc, RwLock}, +}; + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct StoredOpdsCredentials { + pub username: String, + pub password_verifier: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct OpdsCredentialStatus { + pub configured: bool, + pub username: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct GeneratedOpdsCredentials { + pub username: String, + pub password: String, +} + +#[derive(Clone)] +pub(crate) struct OpdsCredentialStore { + inner: Arc, +} + +struct CredentialStoreInner { + path: Option, + credentials: RwLock>, +} + +impl OpdsCredentialStore { + pub fn load(path: PathBuf) -> io::Result { + let credentials = match fs::read(&path) { + Ok(contents) => Some(serde_json::from_slice(&contents).map_err(io::Error::other)?), + Err(error) if error.kind() == io::ErrorKind::NotFound => None, + Err(error) => return Err(error), + }; + Ok(Self { + inner: Arc::new(CredentialStoreInner { + path: Some(path), + credentials: RwLock::new(credentials), + }), + }) + } + + #[cfg(test)] + pub fn in_memory() -> Self { + Self { + inner: Arc::new(CredentialStoreInner { + path: None, + credentials: RwLock::new(None), + }), + } + } + + pub fn status(&self) -> OpdsCredentialStatus { + let credentials = self + .inner + .credentials + .read() + .expect("OPDS credential store poisoned"); + OpdsCredentialStatus { + configured: credentials.is_some(), + username: credentials + .as_ref() + .map(|credentials| credentials.username.clone()), + } + } + + pub fn get(&self) -> Option { + self.inner + .credentials + .read() + .expect("OPDS credential store poisoned") + .clone() + } + + pub fn set(&self, credentials: StoredOpdsCredentials) -> io::Result<()> { + if let Some(path) = &self.inner.path { + persist(path, &credentials)?; + } + *self + .inner + .credentials + .write() + .expect("OPDS credential store poisoned") = Some(credentials); + Ok(()) + } + + pub fn clear(&self) -> io::Result<()> { + if let Some(path) = &self.inner.path { + match fs::remove_file(path) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + } + *self + .inner + .credentials + .write() + .expect("OPDS credential store poisoned") = None; + Ok(()) + } +} + +fn persist(path: &Path, credentials: &StoredOpdsCredentials) -> io::Result<()> { + let parent = path + .parent() + .ok_or_else(|| io::Error::other("credential path has no parent"))?; + fs::create_dir_all(parent)?; + let temporary = parent.join(format!(".opds-credentials-{}.tmp", uuid::Uuid::new_v4())); + let bytes = serde_json::to_vec(credentials).map_err(io::Error::other)?; + + let mut options = OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(&temporary)?; + if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) { + let _ = fs::remove_file(&temporary); + return Err(error); + } + fs::rename(&temporary, path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn persists_only_username_and_verifier_with_private_permissions() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("opds-credentials.json"); + let store = OpdsCredentialStore::load(path.clone()).unwrap(); + store + .set(StoredOpdsCredentials { + username: "reader".to_string(), + password_verifier: "$argon2id$verifier".to_string(), + }) + .unwrap(); + + let contents = fs::read_to_string(&path).unwrap(); + assert!(contents.contains("reader")); + assert!(contents.contains("$argon2id$verifier")); + assert!(!contents.contains("password\":")); + assert_eq!( + OpdsCredentialStore::load(path) + .unwrap() + .status() + .username + .as_deref(), + Some("reader") + ); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(directory.path().join("opds-credentials.json")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + } + } + + #[test] + fn clear_is_idempotent() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("opds-credentials.json"); + let store = OpdsCredentialStore::load(path.clone()).unwrap(); + store + .set(StoredOpdsCredentials { + username: "reader".to_string(), + password_verifier: "verifier".to_string(), + }) + .unwrap(); + + store.clear().unwrap(); + store.clear().unwrap(); + assert!(!path.exists()); + assert_eq!( + store.status(), + OpdsCredentialStatus { + configured: false, + username: None + } + ); + } +} diff --git a/crates/citadel-opds/src/lib.rs b/crates/citadel-opds/src/lib.rs new file mode 100644 index 00000000..933e4485 --- /dev/null +++ b/crates/citadel-opds/src/lib.rs @@ -0,0 +1,19 @@ +//! Tauri-independent OPDS catalog, authentication, networking, and service lifecycle. + +pub mod assets; +mod auth; +mod catalog; +mod credentials; +mod network; +mod service; + +pub use auth::OpdsBasicAuth; +pub use catalog::{ + router, CatalogBookQuery, CatalogFacet, CatalogFilter, CatalogSort, CatalogSource, +}; +pub use credentials::{GeneratedOpdsCredentials, OpdsCredentialStatus}; +pub use network::{OpdsInterfaceKind, OpdsInterfaceState, OpdsNetworkInterface}; +pub use service::{ + OpdsBindTarget, OpdsErrorCode, OpdsLifecycleState, OpdsService, OpdsServiceStatus, + OpdsStartConfig, OpdsStatusError, +}; diff --git a/crates/citadel-opds/src/network.rs b/crates/citadel-opds/src/network.rs new file mode 100644 index 00000000..2e1ea452 --- /dev/null +++ b/crates/citadel-opds/src/network.rs @@ -0,0 +1,677 @@ +use std::{ + collections::BTreeSet, + io, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV6}, +}; + +use netdev::interface::types::InterfaceType; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub enum OpdsInterfaceKind { + Lan, + Vpn, + Loopback, + Other, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub enum OpdsInterfaceState { + Up, + Down, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct OpdsNetworkInterface { + pub id: String, + pub label: String, + pub kind: OpdsInterfaceKind, + pub state: OpdsInterfaceState, + pub addresses: Vec, + pub shareable: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum AddressScope { + Private, + Shared, + LinkLocal, + Loopback, + Global, + Other, + Unspecified, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct InterfaceAddress { + pub address: IpAddr, + pub scope: AddressScope, + pub deprecated: bool, + pub tentative: bool, + pub duplicated: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct InterfaceSnapshot { + pub id: String, + pub label: String, + pub state: OpdsInterfaceState, + pub kind: OpdsInterfaceKind, + pub addresses: Vec, +} + +impl InterfaceSnapshot { + pub fn bindable_addresses(&self, port: u16) -> Vec { + self.addresses + .iter() + .filter_map(|address| match (address.address, &address.scope) { + ( + IpAddr::V4(ip), + AddressScope::Private | AddressScope::Shared | AddressScope::Global, + ) => Some(SocketAddr::new(IpAddr::V4(ip), port)), + (IpAddr::V6(ip), AddressScope::Private | AddressScope::Global) + if !address.deprecated && !address.tentative && !address.duplicated => + { + Some(SocketAddr::V6(SocketAddrV6::new(ip, port, 0, 0))) + } + _ => None, + }) + .collect() + } + + pub fn public(&self) -> OpdsNetworkInterface { + let addresses = self + .bindable_addresses(1) + .into_iter() + .map(|address| address.ip().to_string()) + .collect::>(); + OpdsNetworkInterface { + id: self.id.clone(), + label: self.label.clone(), + kind: self.kind.clone(), + state: self.state.clone(), + shareable: self.kind != OpdsInterfaceKind::Loopback && !addresses.is_empty(), + addresses, + } + } +} + +pub(crate) trait InterfaceProvider: Send { + fn snapshot(&mut self) -> io::Result>; +} + +pub(crate) struct NetdevInterfaceProvider; + +impl InterfaceProvider for NetdevInterfaceProvider { + fn snapshot(&mut self) -> io::Result> { + Ok(netdev::get_interfaces() + .into_iter() + .map(|interface| { + let addresses = interface + .ipv4 + .iter() + .map(|network| InterfaceAddress { + address: IpAddr::V4(network.addr()), + scope: ipv4_scope(network.addr()), + deprecated: false, + tentative: false, + duplicated: false, + }) + .chain(interface.ipv6.iter().enumerate().map(|(index, network)| { + let flags = interface + .ipv6_addr_flags + .get(index) + .copied() + .unwrap_or_default(); + InterfaceAddress { + address: IpAddr::V6(network.addr()), + scope: ipv6_scope(network.addr()), + deprecated: flags.deprecated, + tentative: flags.tentative, + duplicated: flags.duplicated, + } + })) + .collect::>(); + let kind = classify( + &interface.name, + interface.if_type, + interface.is_loopback(), + interface.is_point_to_point(), + interface.is_physical(), + &addresses, + ); + InterfaceSnapshot { + id: interface.name.clone(), + label: interface + .friendly_name + .clone() + .filter(|label| !label.trim().is_empty()) + .unwrap_or_else(|| interface.name.clone()), + state: if interface.is_up() && interface.is_running() { + OpdsInterfaceState::Up + } else { + OpdsInterfaceState::Down + }, + kind, + addresses, + } + }) + .collect()) + } +} + +fn classify( + id: &str, + interface_type: InterfaceType, + loopback: bool, + point_to_point: bool, + physical: bool, + addresses: &[InterfaceAddress], +) -> OpdsInterfaceKind { + let name = id.to_ascii_lowercase(); + let vpn_name = [ + "utun", + "tun", + "tap", + "wg", + "tailscale", + "proton", + "nordlynx", + "ipsec", + "ppp", + ] + .iter() + .any(|prefix| name.starts_with(prefix)); + let virtual_name = [ + "awdl", "llw", "bridge", "br-", "docker", "veth", "virbr", "vmenet", "vmnet", + ] + .iter() + .any(|prefix| name.starts_with(prefix)); + + if loopback + || interface_type == InterfaceType::Loopback + || (!addresses.is_empty() + && addresses + .iter() + .all(|address| address.scope == AddressScope::Loopback)) + { + OpdsInterfaceKind::Loopback + } else if point_to_point + || vpn_name + || matches!( + interface_type, + InterfaceType::Tunnel | InterfaceType::Ppp | InterfaceType::ProprietaryVirtual + ) + { + OpdsInterfaceKind::Vpn + } else if virtual_name || interface_type == InterfaceType::Bridge { + OpdsInterfaceKind::Other + } else if physical + || matches!( + interface_type, + InterfaceType::Ethernet + | InterfaceType::FastEthernetT + | InterfaceType::FastEthernetFx + | InterfaceType::GigabitEthernet + | InterfaceType::Wireless80211 + ) + { + OpdsInterfaceKind::Lan + } else { + OpdsInterfaceKind::Other + } +} + +pub(crate) fn ipv4_scope(address: Ipv4Addr) -> AddressScope { + let octets = address.octets(); + if address.is_unspecified() { + AddressScope::Unspecified + } else if address.is_loopback() { + AddressScope::Loopback + } else if address.is_link_local() { + AddressScope::LinkLocal + } else if address.is_private() { + AddressScope::Private + } else if octets[0] == 100 && (64..=127).contains(&octets[1]) { + AddressScope::Shared + } else if address.is_multicast() || address.is_broadcast() || octets[0] >= 240 { + AddressScope::Other + } else { + AddressScope::Global + } +} + +pub(crate) fn ipv6_scope(address: Ipv6Addr) -> AddressScope { + let first = address.segments()[0]; + if address.is_unspecified() { + AddressScope::Unspecified + } else if address.is_loopback() { + AddressScope::Loopback + } else if (first & 0xffc0) == 0xfe80 { + AddressScope::LinkLocal + } else if (first & 0xfe00) == 0xfc00 { + AddressScope::Private + } else if (first & 0xe000) == 0x2000 { + AddressScope::Global + } else { + AddressScope::Other + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum WaitingReason { + NoLocalInterface, + SelectedInterfaceMissing(String), + SelectedInterfaceDown(String), + SelectedInterfaceHasNoUsableAddress(String), + LoopbackCannotBeShared(String), + NoConfiguredAddress, + InvalidConfiguredAddress(String), +} + +impl WaitingReason { + pub fn message(&self) -> String { + match self { + Self::NoLocalInterface => { + "No active local interface has a usable IP address.".to_string() + } + Self::SelectedInterfaceMissing(id) => { + format!("The selected interface ({id}) is unavailable.") + } + Self::SelectedInterfaceDown(id) => { + format!("The selected interface ({id}) is currently down.") + } + Self::SelectedInterfaceHasNoUsableAddress(id) => { + format!("The selected interface ({id}) has no usable local address.") + } + Self::LoopbackCannotBeShared(id) => { + format!("The selected interface ({id}) is loopback-only and cannot be shared.") + } + Self::NoConfiguredAddress => "No explicit listener address was configured.".to_string(), + Self::InvalidConfiguredAddress(address) => { + format!("The configured listener address ({address}) is invalid.") + } + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum BindPlan { + Listen(BTreeSet), + Wait(WaitingReason), +} + +pub(crate) fn plan_bindings( + interfaces: &[InterfaceSnapshot], + target: &super::service::OpdsBindTarget, + port: u16, +) -> BindPlan { + if let super::service::OpdsBindTarget::Addresses { addresses } = target { + if addresses.is_empty() { + return BindPlan::Wait(WaitingReason::NoConfiguredAddress); + } + let mut sockets = BTreeSet::new(); + for address in addresses { + let Ok(address) = address.parse::() else { + return BindPlan::Wait(WaitingReason::InvalidConfiguredAddress(address.clone())); + }; + sockets.insert(SocketAddr::new(address, port)); + } + return BindPlan::Listen(sockets); + } + + let selected = match target { + super::service::OpdsBindTarget::AllLocalNetworks => interfaces + .iter() + .filter(|interface| { + interface.kind == OpdsInterfaceKind::Lan + && interface.state == OpdsInterfaceState::Up + }) + .collect::>(), + super::service::OpdsBindTarget::Interface { id } => { + let Some(interface) = interfaces.iter().find(|interface| interface.id == *id) else { + return BindPlan::Wait(WaitingReason::SelectedInterfaceMissing(id.clone())); + }; + if interface.kind == OpdsInterfaceKind::Loopback { + return BindPlan::Wait(WaitingReason::LoopbackCannotBeShared(id.clone())); + } + if interface.state != OpdsInterfaceState::Up { + return BindPlan::Wait(WaitingReason::SelectedInterfaceDown(id.clone())); + } + vec![interface] + } + super::service::OpdsBindTarget::Addresses { .. } => unreachable!(), + }; + + let addresses = selected + .into_iter() + .flat_map(|interface| interface.bindable_addresses(port)) + .collect::>(); + if addresses.is_empty() { + BindPlan::Wait(match target { + super::service::OpdsBindTarget::AllLocalNetworks => WaitingReason::NoLocalInterface, + super::service::OpdsBindTarget::Interface { id } => { + WaitingReason::SelectedInterfaceHasNoUsableAddress(id.clone()) + } + super::service::OpdsBindTarget::Addresses { .. } => unreachable!(), + }) + } else { + BindPlan::Listen(addresses) + } +} + +pub(crate) fn advertised_url(address: SocketAddr) -> String { + format!("http://{address}/opds") +} + +#[cfg(test)] +mod tests { + use std::{ + collections::BTreeSet, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, + }; + + use super::*; + use crate::service::OpdsBindTarget; + + fn interface( + id: &str, + kind: OpdsInterfaceKind, + state: OpdsInterfaceState, + addresses: Vec, + ) -> InterfaceSnapshot { + InterfaceSnapshot { + id: id.to_string(), + label: format!("{id} label"), + kind, + state, + addresses, + } + } + + fn interface_address(address: IpAddr, scope: AddressScope) -> InterfaceAddress { + InterfaceAddress { + address, + scope, + deprecated: false, + tentative: false, + duplicated: false, + } + } + + fn ipv4(address: [u8; 4]) -> InterfaceAddress { + let address = Ipv4Addr::from(address); + interface_address(IpAddr::V4(address), ipv4_scope(address)) + } + + fn ipv6(address: &str) -> InterfaceAddress { + let address = address.parse::().unwrap(); + interface_address(IpAddr::V6(address), ipv6_scope(address)) + } + + fn listen_addresses(plan: BindPlan) -> BTreeSet { + match plan { + BindPlan::Listen(addresses) => addresses, + BindPlan::Wait(reason) => panic!("expected listening plan, got {reason:?}"), + } + } + + #[test] + fn all_local_networks_binds_only_exact_usable_lan_addresses() { + let interfaces = [ + interface( + "en0", + OpdsInterfaceKind::Lan, + OpdsInterfaceState::Up, + vec![ipv4([192, 168, 1, 42]), ipv6("fd12:3456::42")], + ), + interface( + "utun4", + OpdsInterfaceKind::Vpn, + OpdsInterfaceState::Up, + vec![ipv4([10, 0, 0, 8]), ipv6("2001:db8::8")], + ), + interface( + "en1", + OpdsInterfaceKind::Lan, + OpdsInterfaceState::Down, + vec![ipv4([192, 168, 2, 8])], + ), + ]; + + let addresses = listen_addresses(plan_bindings( + &interfaces, + &OpdsBindTarget::AllLocalNetworks, + 8080, + )); + + assert_eq!( + addresses, + BTreeSet::from([ + "192.168.1.42:8080".parse().unwrap(), + "[fd12:3456::42]:8080".parse().unwrap(), + ]) + ); + assert!(!addresses + .iter() + .any(|address| address.ip().is_unspecified())); + } + + #[test] + fn selected_interface_binds_only_that_interface() { + let interfaces = [ + interface( + "en0", + OpdsInterfaceKind::Lan, + OpdsInterfaceState::Up, + vec![ipv4([192, 168, 1, 42])], + ), + interface( + "en1", + OpdsInterfaceKind::Lan, + OpdsInterfaceState::Up, + vec![ipv4([192, 168, 2, 42]), ipv6("2001:db8::42")], + ), + ]; + + let addresses = listen_addresses(plan_bindings( + &interfaces, + &OpdsBindTarget::Interface { + id: "en1".to_string(), + }, + 8080, + )); + + assert_eq!( + addresses, + BTreeSet::from([ + "192.168.2.42:8080".parse().unwrap(), + "[2001:db8::42]:8080".parse().unwrap(), + ]) + ); + } + + #[test] + fn selected_interface_reports_missing_down_and_unusable_states() { + let missing = plan_bindings( + &[], + &OpdsBindTarget::Interface { + id: "en0".to_string(), + }, + 8080, + ); + assert_eq!( + missing, + BindPlan::Wait(WaitingReason::SelectedInterfaceMissing("en0".to_string())) + ); + + let down = plan_bindings( + &[interface( + "en0", + OpdsInterfaceKind::Lan, + OpdsInterfaceState::Down, + vec![ipv4([192, 168, 1, 42])], + )], + &OpdsBindTarget::Interface { + id: "en0".to_string(), + }, + 8080, + ); + assert_eq!( + down, + BindPlan::Wait(WaitingReason::SelectedInterfaceDown("en0".to_string())) + ); + + let unusable = plan_bindings( + &[interface( + "en0", + OpdsInterfaceKind::Lan, + OpdsInterfaceState::Up, + vec![ipv4([169, 254, 1, 42])], + )], + &OpdsBindTarget::Interface { + id: "en0".to_string(), + }, + 8080, + ); + assert_eq!( + unusable, + BindPlan::Wait(WaitingReason::SelectedInterfaceHasNoUsableAddress( + "en0".to_string() + )) + ); + } + + #[test] + fn loopback_interface_cannot_be_selected_for_sharing() { + let plan = plan_bindings( + &[interface( + "lo0", + OpdsInterfaceKind::Loopback, + OpdsInterfaceState::Up, + vec![interface_address( + IpAddr::V4(Ipv4Addr::LOCALHOST), + AddressScope::Loopback, + )], + )], + &OpdsBindTarget::Interface { + id: "lo0".to_string(), + }, + 8080, + ); + + assert_eq!( + plan, + BindPlan::Wait(WaitingReason::LoopbackCannotBeShared("lo0".to_string())) + ); + } + + #[test] + fn bindable_addresses_excludes_link_local_and_unready_ipv6_addresses() { + let mut deprecated = ipv6("fd12::2"); + deprecated.deprecated = true; + let mut tentative = ipv6("fd12::3"); + tentative.tentative = true; + let mut duplicated = ipv6("fd12::4"); + duplicated.duplicated = true; + let snapshot = interface( + "en0", + OpdsInterfaceKind::Lan, + OpdsInterfaceState::Up, + vec![ + ipv6("fd12::1"), + ipv6("fe80::1"), + deprecated, + tentative, + duplicated, + ], + ); + + assert_eq!( + snapshot.bindable_addresses(8080), + vec!["[fd12::1]:8080".parse().unwrap()] + ); + } + + #[test] + fn advertised_url_brackets_ipv6_addresses() { + assert_eq!( + advertised_url("[2001:db8::42]:8080".parse().unwrap()), + "http://[2001:db8::42]:8080/opds" + ); + } + + #[test] + fn explicit_addresses_do_not_depend_on_interface_enumeration() { + let plan = plan_bindings( + &[], + &OpdsBindTarget::Addresses { + addresses: vec!["127.0.0.1".to_string(), "::1".to_string()], + }, + 8080, + ); + assert_eq!( + plan, + BindPlan::Listen(BTreeSet::from([ + "127.0.0.1:8080".parse().unwrap(), + "[::1]:8080".parse().unwrap(), + ])) + ); + + assert_eq!( + plan_bindings( + &[], + &OpdsBindTarget::Addresses { + addresses: vec!["not-an-address".to_string()], + }, + 8080, + ), + BindPlan::Wait(WaitingReason::InvalidConfiguredAddress( + "not-an-address".to_string() + )) + ); + } + + #[test] + fn public_interface_exposes_bindable_addresses_and_shareability() { + let public = interface( + "en0", + OpdsInterfaceKind::Lan, + OpdsInterfaceState::Up, + vec![ + ipv4([192, 168, 1, 42]), + ipv6("fd12::42"), + ipv4([169, 254, 1, 42]), + ], + ) + .public(); + + assert_eq!( + public, + OpdsNetworkInterface { + id: "en0".to_string(), + label: "en0 label".to_string(), + kind: OpdsInterfaceKind::Lan, + state: OpdsInterfaceState::Up, + addresses: vec!["192.168.1.42".to_string(), "fd12::42".to_string()], + shareable: true, + } + ); + + let loopback = interface( + "lo0", + OpdsInterfaceKind::Loopback, + OpdsInterfaceState::Up, + vec![interface_address( + IpAddr::V4(Ipv4Addr::LOCALHOST), + AddressScope::Loopback, + )], + ) + .public(); + assert!(loopback.addresses.is_empty()); + assert!(!loopback.shareable); + } +} diff --git a/crates/citadel-opds/src/service.rs b/crates/citadel-opds/src/service.rs new file mode 100644 index 00000000..f7b46d8a --- /dev/null +++ b/crates/citadel-opds/src/service.rs @@ -0,0 +1,1550 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + io, + net::{SocketAddr, TcpListener as StdTcpListener}, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, Mutex, + }, + time::Duration, +}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use futures_util::future::join_all; +use rand_core::{OsRng, RngCore}; +use serde::{Deserialize, Serialize}; +use tokio::{ + sync::{oneshot, Notify}, + task::JoinHandle, +}; + +use super::{ + auth::{create_credentials, OpdsAuthCredentials, OpdsBasicAuth}, + credentials::{ + GeneratedOpdsCredentials, OpdsCredentialStatus, OpdsCredentialStore, StoredOpdsCredentials, + }, + network::{ + advertised_url, plan_bindings, InterfaceProvider, InterfaceSnapshot, + NetdevInterfaceProvider, OpdsNetworkInterface, WaitingReason, + }, + router, CatalogSource, +}; + +const MONITOR_INTERVAL: Duration = Duration::from_secs(2); +const LISTENER_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); +const WORKER_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(6); + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, specta::Type)] +#[serde(rename_all = "camelCase", tag = "type")] +pub enum OpdsBindTarget { + AllLocalNetworks, + Interface { id: String }, + Addresses { addresses: Vec }, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct OpdsStartConfig { + pub target: OpdsBindTarget, + pub port: u32, + pub authentication_enabled: bool, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub enum OpdsLifecycleState { + Stopped, + Starting, + Running, + WaitingForInterface, + Error, + Stopping, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub enum OpdsErrorCode { + InvalidPort, + LibraryNotReady, + ConfigurationConflict, + InterfaceUnavailable, + InterfaceEnumerationFailed, + PortUnavailable, + ListenerFailed, + InvalidCredentials, + CredentialsRequired, + CredentialStorageFailed, + Unexpected, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct OpdsStatusError { + pub code: OpdsErrorCode, + pub message: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct OpdsServiceStatus { + pub state: OpdsLifecycleState, + pub target: Option, + pub port: Option, + pub active_library_id: Option, + pub urls: Vec, + pub error: Option, +} + +impl Default for OpdsServiceStatus { + fn default() -> Self { + Self { + state: OpdsLifecycleState::Stopped, + target: None, + port: None, + active_library_id: None, + urls: Vec::new(), + error: None, + } + } +} + +trait InterfaceSnapshots: Send + Sync { + fn snapshot(&self) -> io::Result>; +} + +struct NetdevInterfaceSnapshots; + +impl InterfaceSnapshots for NetdevInterfaceSnapshots { + fn snapshot(&self) -> io::Result> { + let mut provider = NetdevInterfaceProvider; + provider.snapshot() + } +} + +trait ListenerFactory: Send + Sync { + fn start( + &self, + address: SocketAddr, + source: Arc, + auth: OpdsBasicAuth, + tracker: Arc, + ) -> io::Result; +} + +struct TcpListenerFactory; + +impl ListenerFactory for TcpListenerFactory { + fn start( + &self, + address: SocketAddr, + source: Arc, + auth: OpdsBasicAuth, + tracker: Arc, + ) -> io::Result { + let listener = StdTcpListener::bind(address)?; + listener.set_nonblocking(true)?; + let listener = tokio::net::TcpListener::from_std(listener)?; + let (shutdown, shutdown_receiver) = oneshot::channel(); + let app = router(source, auth); + let completion = tracker.register(); + let task = tokio::spawn(async move { + let _completion = completion; + axum::serve(listener, app) + .with_graceful_shutdown(async move { + let _ = shutdown_receiver.await; + }) + .await + }); + Ok(ServerTask { + shutdown: Some(shutdown), + task: Some(task), + }) + } +} + +struct ServiceDependencies { + interfaces: Arc, + listeners: Arc, + monitor_interval: Duration, + worker_shutdown_timeout: Duration, +} + +impl Default for ServiceDependencies { + fn default() -> Self { + Self { + interfaces: Arc::new(NetdevInterfaceSnapshots), + listeners: Arc::new(TcpListenerFactory), + monitor_interval: MONITOR_INTERVAL, + worker_shutdown_timeout: WORKER_SHUTDOWN_TIMEOUT, + } + } +} + +struct RunningController { + config: OpdsStartConfig, + stop: oneshot::Sender<()>, + worker: JoinHandle<()>, +} + +struct ServiceInner { + status: Arc>, + controller: tokio::sync::Mutex>, + source: Arc, + dependencies: ServiceDependencies, + listener_tracker: Arc, + credentials: OpdsCredentialStore, +} + +#[derive(Clone)] +pub struct OpdsService { + inner: Arc, +} + +impl OpdsService { + pub fn new( + source: Arc, + credential_path: std::path::PathBuf, + ) -> io::Result { + Ok(Self::with_dependencies_and_credentials( + source, + ServiceDependencies::default(), + OpdsCredentialStore::load(credential_path)?, + )) + } + + #[cfg(test)] + fn with_dependencies( + source: Arc, + dependencies: ServiceDependencies, + ) -> Self { + Self::with_dependencies_and_credentials( + source, + dependencies, + OpdsCredentialStore::in_memory(), + ) + } + + fn with_dependencies_and_credentials( + source: Arc, + dependencies: ServiceDependencies, + credentials: OpdsCredentialStore, + ) -> Self { + Self { + inner: Arc::new(ServiceInner { + status: Arc::new(Mutex::new(OpdsServiceStatus::default())), + controller: tokio::sync::Mutex::new(None), + source, + dependencies, + listener_tracker: Arc::new(ListenerTracker::default()), + credentials, + }), + } + } + + pub fn credential_status(&self) -> OpdsCredentialStatus { + self.inner.credentials.status() + } + + pub async fn configure_credentials( + &self, + username: String, + password: String, + ) -> Result { + let mut controller = self.inner.controller.lock().await; + reap_finished_controller(&mut controller, &self.inner.status).await; + ensure_credentials_editable(&controller)?; + let username = normalize_username(username)?; + if password.is_empty() { + return Err(invalid_credentials("Enter a password.")); + } + let credentials = + tokio::task::spawn_blocking(move || create_credentials(username, password.as_bytes())) + .await + .map_err(|_| credential_storage_error())? + .map_err(|_| credential_storage_error())?; + self.inner + .credentials + .set(StoredOpdsCredentials { + username: credentials.username, + password_verifier: credentials.verifier, + }) + .map_err(|_| credential_storage_error())?; + drop(controller); + Ok(self.credential_status()) + } + + pub async fn clear_credentials(&self) -> Result { + let mut controller = self.inner.controller.lock().await; + reap_finished_controller(&mut controller, &self.inner.status).await; + ensure_credentials_editable(&controller)?; + self.inner + .credentials + .clear() + .map_err(|_| credential_storage_error())?; + drop(controller); + Ok(self.credential_status()) + } + + pub async fn generate_credentials( + &self, + username: String, + ) -> Result { + let username = normalize_username(username)?; + let mut bytes = [0_u8; 18]; + OsRng.fill_bytes(&mut bytes); + let password = URL_SAFE_NO_PAD.encode(bytes); + self.configure_credentials(username.clone(), password.clone()) + .await?; + Ok(GeneratedOpdsCredentials { username, password }) + } + + pub async fn status(&self) -> OpdsServiceStatus { + if let Ok(mut controller) = self.inner.controller.try_lock() { + reap_finished_controller(&mut controller, &self.inner.status).await; + } + + let mut status = status_snapshot(&self.inner.status); + if status.state == OpdsLifecycleState::Stopped { + status.active_library_id = None; + return status; + } + status.active_library_id = active_library_id(self.inner.source.clone()).await; + status + } + + pub async fn list_interfaces(&self) -> Result, OpdsStatusError> { + snapshot_interfaces(self.inner.dependencies.interfaces.clone()) + .await + .map(|interfaces| { + interfaces + .into_iter() + .map(|interface| interface.public()) + .collect() + }) + .map_err(|_| OpdsStatusError { + code: OpdsErrorCode::InterfaceEnumerationFailed, + message: "Citadel could not list network interfaces.".to_string(), + }) + } + + pub async fn start( + &self, + config: OpdsStartConfig, + ) -> Result { + let mut controller = self.inner.controller.lock().await; + reap_finished_controller(&mut controller, &self.inner.status).await; + if let Some(running) = controller.as_ref() { + if running.config == config { + drop(controller); + return Ok(self.status().await); + } + return Err(OpdsStatusError { + code: OpdsErrorCode::ConfigurationConflict, + message: "Sharing is already active with different network settings. Stop it before changing the interface or port.".to_string(), + }); + } + + let auth = if config.authentication_enabled { + let Some(credentials) = self.inner.credentials.get() else { + return Err(OpdsStatusError { + code: OpdsErrorCode::CredentialsRequired, + message: "Set a username and password before enabling authentication." + .to_string(), + }); + }; + OpdsBasicAuth::enabled(OpdsAuthCredentials { + username: credentials.username, + verifier: credentials.password_verifier, + }) + .map_err(|_| credential_storage_error())? + } else { + OpdsBasicAuth::disabled() + }; + + set_configured_status( + &self.inner.status, + &config, + OpdsLifecycleState::Starting, + None, + Vec::new(), + None, + ); + let Ok(port) = u16::try_from(config.port) + .ok() + .filter(|port| *port != 0) + .ok_or(()) + else { + set_configured_status( + &self.inner.status, + &config, + OpdsLifecycleState::Error, + Some(OpdsStatusError { + code: OpdsErrorCode::InvalidPort, + message: "Choose a port between 1 and 65535.".to_string(), + }), + Vec::new(), + None, + ); + drop(controller); + return Ok(self.status().await); + }; + + let library_id = active_library_id(self.inner.source.clone()).await; + let Some(library_id) = library_id else { + set_configured_status( + &self.inner.status, + &config, + OpdsLifecycleState::Error, + Some(OpdsStatusError { + code: OpdsErrorCode::LibraryNotReady, + message: "Open a library before starting sharing.".to_string(), + }), + Vec::new(), + None, + ); + drop(controller); + return Ok(self.status().await); + }; + + let mut servers = BTreeMap::new(); + match snapshot_target_interfaces(self.inner.dependencies.interfaces.clone(), &config.target) + .await + { + Ok(interfaces) => { + apply_plan( + &interfaces, + &config, + port, + &mut servers, + self.inner.dependencies.listeners.as_ref(), + self.inner.listener_tracker.clone(), + self.inner.source.clone(), + auth.clone(), + &self.inner.status, + Some(library_id.clone()), + ) + .await; + } + Err(_) => { + set_configured_status( + &self.inner.status, + &config, + OpdsLifecycleState::Error, + Some(interface_enumeration_error(false)), + Vec::new(), + Some(library_id), + ); + } + } + + let (stop, stop_receiver) = oneshot::channel(); + let worker = tokio::spawn(monitor_service( + self.inner.dependencies.interfaces.clone(), + self.inner.dependencies.listeners.clone(), + self.inner.listener_tracker.clone(), + self.inner.source.clone(), + auth, + self.inner.status.clone(), + config.clone(), + port, + servers, + stop_receiver, + self.inner.dependencies.monitor_interval, + )); + *controller = Some(RunningController { + config, + stop, + worker, + }); + drop(controller); + Ok(self.status().await) + } + + pub async fn stop(&self) -> OpdsServiceStatus { + let mut controller = self.inner.controller.lock().await; + let Some(running) = controller.take() else { + set_stopped_status(&self.inner.status); + drop(controller); + return self.status().await; + }; + set_simple_status( + &self.inner.status, + OpdsLifecycleState::Stopping, + None, + Vec::new(), + ); + let stopped = stop_controller( + running, + self.inner.listener_tracker.clone(), + self.inner.dependencies.worker_shutdown_timeout, + ) + .await; + if stopped { + set_stopped_status(&self.inner.status); + } else { + set_simple_status( + &self.inner.status, + OpdsLifecycleState::Error, + Some(OpdsStatusError { + code: OpdsErrorCode::ListenerFailed, + message: "Citadel could not confirm that every OPDS listener stopped." + .to_string(), + }), + Vec::new(), + ); + } + drop(controller); + self.status().await + } +} + +fn ensure_credentials_editable( + controller: &Option, +) -> Result<(), OpdsStatusError> { + if controller.is_some() { + return Err(OpdsStatusError { + code: OpdsErrorCode::ConfigurationConflict, + message: "Stop sharing before changing credentials.".to_string(), + }); + } + Ok(()) +} + +fn normalize_username(username: String) -> Result { + let username = username.trim().to_string(); + if username.is_empty() { + return Err(invalid_credentials("Enter a username.")); + } + if username.contains(':') || username.chars().any(char::is_control) { + return Err(invalid_credentials( + "The username cannot contain a colon or control characters.", + )); + } + Ok(username) +} + +fn invalid_credentials(message: &str) -> OpdsStatusError { + OpdsStatusError { + code: OpdsErrorCode::InvalidCredentials, + message: message.to_string(), + } +} + +fn credential_storage_error() -> OpdsStatusError { + OpdsStatusError { + code: OpdsErrorCode::CredentialStorageFailed, + message: "Citadel could not securely save the sharing credentials.".to_string(), + } +} + +struct ServerTask { + shutdown: Option>, + task: Option>>, +} + +#[derive(Default)] +struct ListenerTracker { + active: AtomicUsize, + empty: Notify, +} + +impl ListenerTracker { + fn register(self: &Arc) -> ListenerCompletion { + self.active.fetch_add(1, Ordering::AcqRel); + ListenerCompletion { + tracker: self.clone(), + } + } + + async fn wait_until_empty(&self) { + loop { + let notified = self.empty.notified(); + if self.active.load(Ordering::Acquire) == 0 { + return; + } + notified.await; + } + } +} + +struct ListenerCompletion { + tracker: Arc, +} + +impl Drop for ListenerCompletion { + fn drop(&mut self) { + if self.tracker.active.fetch_sub(1, Ordering::AcqRel) == 1 { + self.tracker.empty.notify_waiters(); + } + } +} + +impl Drop for ServerTask { + fn drop(&mut self) { + if let Some(task) = self.task.as_ref() { + task.abort(); + } + } +} + +async fn snapshot_interfaces( + interfaces: Arc, +) -> io::Result> { + tokio::task::spawn_blocking(move || interfaces.snapshot()) + .await + .map_err(|_| io::Error::other("interface snapshot task failed"))? +} + +async fn snapshot_target_interfaces( + interfaces: Arc, + target: &OpdsBindTarget, +) -> io::Result> { + if matches!(target, OpdsBindTarget::Addresses { .. }) { + Ok(Vec::new()) + } else { + snapshot_interfaces(interfaces).await + } +} + +async fn active_library_id(source: Arc) -> Option { + tokio::task::spawn_blocking(move || source.active_library_id().ok()) + .await + .ok() + .flatten() +} + +async fn apply_plan( + interfaces: &[InterfaceSnapshot], + config: &OpdsStartConfig, + port: u16, + servers: &mut BTreeMap, + listeners: &dyn ListenerFactory, + tracker: Arc, + source: Arc, + auth: OpdsBasicAuth, + status: &Mutex, + library_id: Option, +) { + match plan_bindings(interfaces, &config.target, port) { + super::network::BindPlan::Wait(reason) => { + shutdown_servers(servers).await; + set_configured_status( + status, + config, + OpdsLifecycleState::WaitingForInterface, + Some(waiting_error(&reason)), + Vec::new(), + library_id, + ); + } + super::network::BindPlan::Listen(desired) => { + let stale = servers + .keys() + .filter(|address| !desired.contains(address)) + .copied() + .collect::>(); + let mut stale_servers = BTreeMap::new(); + for address in stale { + if let Some(server) = servers.remove(&address) { + stale_servers.insert(address, server); + } + } + shutdown_servers(&mut stale_servers).await; + + if let Err(error) = + start_missing_servers(servers, &desired, listeners, tracker, source, auth) + { + shutdown_servers(servers).await; + set_configured_status( + status, + config, + OpdsLifecycleState::Error, + Some(bind_error(error)), + Vec::new(), + library_id, + ); + return; + } + set_configured_status( + status, + config, + OpdsLifecycleState::Running, + None, + urls(&desired), + library_id, + ); + } + } +} + +fn start_missing_servers( + servers: &mut BTreeMap, + desired: &BTreeSet, + listeners: &dyn ListenerFactory, + tracker: Arc, + source: Arc, + auth: OpdsBasicAuth, +) -> io::Result<()> { + for address in desired { + if address.ip().is_unspecified() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "wildcard listeners are forbidden", + )); + } + if !servers.contains_key(address) { + servers.insert( + *address, + listeners.start(*address, source.clone(), auth.clone(), tracker.clone())?, + ); + } + } + Ok(()) +} + +async fn monitor_service( + interfaces: Arc, + listeners: Arc, + tracker: Arc, + source: Arc, + auth: OpdsBasicAuth, + status: Arc>, + config: OpdsStartConfig, + port: u16, + mut servers: BTreeMap, + mut stop: oneshot::Receiver<()>, + interval: Duration, +) { + let mut ticker = tokio::time::interval(interval); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + ticker.tick().await; + loop { + tokio::select! { + _ = &mut stop => break, + _ = ticker.tick() => {} + } + + if servers + .values() + .any(|server| server.task.as_ref().is_some_and(JoinHandle::is_finished)) + { + shutdown_servers(&mut servers).await; + set_configured_status( + &status, + &config, + OpdsLifecycleState::Error, + Some(OpdsStatusError { + code: OpdsErrorCode::ListenerFailed, + message: "An OPDS listener stopped unexpectedly; Citadel will retry." + .to_string(), + }), + Vec::new(), + None, + ); + continue; + } + + let snapshot = match snapshot_target_interfaces(interfaces.clone(), &config.target).await { + Ok(snapshot) => snapshot, + Err(_) => { + shutdown_servers(&mut servers).await; + set_configured_status( + &status, + &config, + OpdsLifecycleState::Error, + Some(interface_enumeration_error(true)), + Vec::new(), + None, + ); + continue; + } + }; + let library_id = active_library_id(source.clone()).await; + apply_plan( + &snapshot, + &config, + port, + &mut servers, + listeners.as_ref(), + tracker.clone(), + source.clone(), + auth.clone(), + &status, + library_id, + ) + .await; + } + shutdown_servers(&mut servers).await; +} + +async fn reap_finished_controller( + controller: &mut Option, + status: &Mutex, +) { + if !controller + .as_ref() + .is_some_and(|running| running.worker.is_finished()) + { + return; + } + let running = controller.take().expect("finished controller disappeared"); + let _ = running.worker.await; + set_simple_status( + status, + OpdsLifecycleState::Error, + Some(OpdsStatusError { + code: OpdsErrorCode::ListenerFailed, + message: "The OPDS service stopped unexpectedly. Start sharing again to retry." + .to_string(), + }), + Vec::new(), + ); +} + +async fn stop_controller( + controller: RunningController, + tracker: Arc, + worker_shutdown_timeout: Duration, +) -> bool { + let _ = controller.stop.send(()); + let mut worker = controller.worker; + if tokio::time::timeout(worker_shutdown_timeout, &mut worker) + .await + .is_err() + { + worker.abort(); + let _ = worker.await; + } + tokio::time::timeout(LISTENER_SHUTDOWN_TIMEOUT, tracker.wait_until_empty()) + .await + .is_ok() +} + +async fn shutdown_servers(servers: &mut BTreeMap) { + let mut servers = std::mem::take(servers).into_values().collect::>(); + for server in &mut servers { + if let Some(shutdown) = server.shutdown.take() { + let _ = shutdown.send(()); + } + } + let mut tasks = servers + .iter_mut() + .filter_map(|server| server.task.take()) + .collect::>(); + if tokio::time::timeout(LISTENER_SHUTDOWN_TIMEOUT, join_all(tasks.iter_mut())) + .await + .is_err() + { + for task in &tasks { + task.abort(); + } + let _ = join_all(tasks).await; + } +} + +fn status_snapshot(status: &Mutex) -> OpdsServiceStatus { + status.lock().expect("OPDS status mutex poisoned").clone() +} + +fn set_stopped_status(status: &Mutex) { + *status.lock().expect("OPDS status mutex poisoned") = OpdsServiceStatus::default(); +} + +fn set_simple_status( + status: &Mutex, + state: OpdsLifecycleState, + error: Option, + urls: Vec, +) { + let mut status = status.lock().expect("OPDS status mutex poisoned"); + status.state = state; + status.error = error; + status.urls = urls; +} + +fn set_configured_status( + status: &Mutex, + config: &OpdsStartConfig, + state: OpdsLifecycleState, + error: Option, + urls: Vec, + library_id: Option, +) { + let mut status = status.lock().expect("OPDS status mutex poisoned"); + status.state = state; + status.target = Some(config.target.clone()); + status.port = Some(config.port); + if library_id.is_some() { + status.active_library_id = library_id; + } + status.error = error; + status.urls = urls; +} + +fn urls(addresses: &BTreeSet) -> Vec { + addresses.iter().copied().map(advertised_url).collect() +} + +fn waiting_error(reason: &WaitingReason) -> OpdsStatusError { + OpdsStatusError { + code: OpdsErrorCode::InterfaceUnavailable, + message: reason.message(), + } +} + +fn interface_enumeration_error(retrying: bool) -> OpdsStatusError { + OpdsStatusError { + code: OpdsErrorCode::InterfaceEnumerationFailed, + message: if retrying { + "Citadel could not inspect network interfaces; it will retry." + } else { + "Citadel could not inspect network interfaces." + } + .to_string(), + } +} + +fn bind_error(error: io::Error) -> OpdsStatusError { + if error.kind() == io::ErrorKind::AddrInUse { + OpdsStatusError { + code: OpdsErrorCode::PortUnavailable, + message: "That port is already in use on the selected network interface.".to_string(), + } + } else { + OpdsStatusError { + code: OpdsErrorCode::Unexpected, + message: "Citadel could not open the requested OPDS listener.".to_string(), + } + } +} + +#[cfg(test)] +mod tests { + use std::{ + net::{IpAddr, Ipv4Addr}, + sync::atomic::{AtomicBool, Ordering}, + }; + + use super::*; + use crate::network::{AddressScope, InterfaceAddress, OpdsInterfaceKind, OpdsInterfaceState}; + + struct TestSource { + library_id: Option, + } + + impl CatalogSource for TestSource { + fn active_library_id(&self) -> Result { + self.library_id + .clone() + .ok_or(libcalibre::CalibreError::LibraryNotInitialized) + } + + fn book_page( + &self, + _query: crate::catalog::CatalogBookQuery, + ) -> Result< + (String, Option, libcalibre::BookPage), + libcalibre::CalibreError, + > { + Ok(( + self.active_library_id()?, + None, + libcalibre::BookPage { + items: Vec::new(), + total: 0, + }, + )) + } + + fn book_file( + &self, + book_id: libcalibre::BookId, + format: &str, + ) -> Result { + Err(libcalibre::CalibreError::BookFileNotFound( + book_id, + format.to_string(), + )) + } + + fn book_cover( + &self, + book_id: libcalibre::BookId, + ) -> Result { + Err(libcalibre::CalibreError::BookCoverNotFound(book_id)) + } + } + + fn test_source() -> Arc { + Arc::new(TestSource { + library_id: Some("test-library".to_string()), + }) + } + + fn missing_source() -> Arc { + Arc::new(TestSource { library_id: None }) + } + + struct FakeInterfaces { + current: Mutex, io::ErrorKind>>, + } + + struct BlockingInterfaces { + snapshot: Vec, + entered: AtomicBool, + release: AtomicBool, + } + + struct HangingMonitorInterfaces { + snapshot: Vec, + calls: AtomicUsize, + release: AtomicBool, + } + + impl InterfaceSnapshots for HangingMonitorInterfaces { + fn snapshot(&self) -> io::Result> { + if self.calls.fetch_add(1, Ordering::AcqRel) != 0 { + while !self.release.load(Ordering::Acquire) { + std::thread::sleep(Duration::from_millis(1)); + } + } + Ok(self.snapshot.clone()) + } + } + + impl InterfaceSnapshots for BlockingInterfaces { + fn snapshot(&self) -> io::Result> { + self.entered.store(true, Ordering::Release); + while !self.release.load(Ordering::Acquire) { + std::thread::sleep(Duration::from_millis(1)); + } + Ok(self.snapshot.clone()) + } + } + + impl FakeInterfaces { + fn new(snapshot: Vec) -> Self { + Self { + current: Mutex::new(Ok(snapshot)), + } + } + + fn set(&self, snapshot: Vec) { + *self.current.lock().unwrap() = Ok(snapshot); + } + + fn set_error(&self, kind: io::ErrorKind) { + *self.current.lock().unwrap() = Err(kind); + } + } + + impl InterfaceSnapshots for FakeInterfaces { + fn snapshot(&self) -> io::Result> { + match &*self.current.lock().unwrap() { + Ok(snapshot) => Ok(snapshot.clone()), + Err(kind) => Err(io::Error::from(*kind)), + } + } + } + + #[derive(Default)] + struct FakeListeners { + active: Arc>>, + fail_bind: AtomicBool, + fail_task: AtomicBool, + hold_shutdown: Arc, + } + + impl ListenerFactory for FakeListeners { + fn start( + &self, + address: SocketAddr, + _source: Arc, + _auth: OpdsBasicAuth, + tracker: Arc, + ) -> io::Result { + if self.fail_bind.swap(false, Ordering::AcqRel) { + return Err(io::Error::from(io::ErrorKind::AddrInUse)); + } + self.active.lock().unwrap().insert(address); + let active = self.active.clone(); + let self_hold_shutdown = self.hold_shutdown.clone(); + let task_should_fail = self.fail_task.swap(false, Ordering::AcqRel); + let completion = tracker.register(); + let (shutdown, shutdown_receiver) = oneshot::channel(); + let task = tokio::spawn(async move { + let _completion = completion; + let _active = FakeActiveListener { active, address }; + let result = if task_should_fail { + Err(io::Error::from(io::ErrorKind::BrokenPipe)) + } else { + let _ = shutdown_receiver.await; + while self_hold_shutdown.load(Ordering::Acquire) { + tokio::time::sleep(Duration::from_millis(1)).await; + } + Ok(()) + }; + result + }); + Ok(ServerTask { + shutdown: Some(shutdown), + task: Some(task), + }) + } + } + + struct FakeActiveListener { + active: Arc>>, + address: SocketAddr, + } + + impl Drop for FakeActiveListener { + fn drop(&mut self) { + self.active.lock().unwrap().remove(&self.address); + } + } + + fn lan(state: OpdsInterfaceState, address: [u8; 4]) -> InterfaceSnapshot { + InterfaceSnapshot { + id: "en0".to_string(), + label: "Ethernet".to_string(), + state, + kind: OpdsInterfaceKind::Lan, + addresses: vec![InterfaceAddress { + address: IpAddr::V4(Ipv4Addr::from(address)), + scope: AddressScope::Private, + deprecated: false, + tentative: false, + duplicated: false, + }], + } + } + + fn config(port: u32) -> OpdsStartConfig { + OpdsStartConfig { + target: OpdsBindTarget::Interface { + id: "en0".to_string(), + }, + port, + authentication_enabled: false, + } + } + + fn service_with( + source: Arc, + interfaces: Arc, + listeners: Arc, + interval: Duration, + ) -> OpdsService { + OpdsService::with_dependencies( + source, + ServiceDependencies { + interfaces, + listeners, + monitor_interval: interval, + worker_shutdown_timeout: Duration::from_millis(100), + }, + ) + } + + async fn wait_for_state( + service: &OpdsService, + expected: OpdsLifecycleState, + ) -> OpdsServiceStatus { + for _ in 0..100 { + let status = service.status().await; + if status.state == expected { + return status; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + panic!( + "timed out waiting for {expected:?}; current status: {:?}", + service.status().await + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn selected_interface_loss_waits_without_broadening_and_recovers() { + let source = test_source(); + let interfaces = Arc::new(FakeInterfaces::new(vec![lan( + OpdsInterfaceState::Up, + [192, 168, 1, 5], + )])); + let listeners = Arc::new(FakeListeners::default()); + let service = service_with( + source, + interfaces.clone(), + listeners.clone(), + Duration::from_millis(10), + ); + + let running = service.start(config(8080)).await.unwrap(); + assert_eq!(running.state, OpdsLifecycleState::Running); + assert_eq!( + *listeners.active.lock().unwrap(), + BTreeSet::from(["192.168.1.5:8080".parse().unwrap()]) + ); + + interfaces.set(vec![lan(OpdsInterfaceState::Down, [192, 168, 1, 5])]); + let waiting = wait_for_state(&service, OpdsLifecycleState::WaitingForInterface).await; + assert_eq!( + waiting.error.unwrap().code, + OpdsErrorCode::InterfaceUnavailable + ); + assert!(listeners.active.lock().unwrap().is_empty()); + + interfaces.set(vec![lan(OpdsInterfaceState::Up, [192, 168, 1, 9])]); + let recovered = wait_for_state(&service, OpdsLifecycleState::Running).await; + assert_eq!(recovered.urls, vec!["http://192.168.1.9:8080/opds"]); + assert_eq!( + *listeners.active.lock().unwrap(), + BTreeSet::from(["192.168.1.9:8080".parse().unwrap()]) + ); + + assert_eq!(service.stop().await.state, OpdsLifecycleState::Stopped); + assert!(listeners.active.lock().unwrap().is_empty()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn concurrent_same_config_starts_are_idempotent_and_conflicts_are_typed() { + let source = test_source(); + let interfaces = Arc::new(FakeInterfaces::new(vec![lan( + OpdsInterfaceState::Up, + [192, 168, 1, 5], + )])); + let listeners = Arc::new(FakeListeners::default()); + let service = service_with( + source, + interfaces, + listeners.clone(), + Duration::from_millis(10), + ); + + let requested = config(8080); + let (first, second) = tokio::join!( + service.start(requested.clone()), + service.start(requested.clone()) + ); + assert_eq!(first.unwrap().state, OpdsLifecycleState::Running); + assert_eq!(second.unwrap().state, OpdsLifecycleState::Running); + assert_eq!(listeners.active.lock().unwrap().len(), 1); + + let conflict = service.start(config(8081)).await.unwrap_err(); + assert_eq!(conflict.code, OpdsErrorCode::ConfigurationConflict); + assert_eq!(service.status().await.port, Some(8080)); + service.stop().await; + assert_eq!(service.stop().await.state, OpdsLifecycleState::Stopped); + } + + #[tokio::test(flavor = "multi_thread")] + async fn credentials_are_backend_owned_and_cannot_change_while_running() { + let source = test_source(); + let interfaces = Arc::new(FakeInterfaces::new(vec![lan( + OpdsInterfaceState::Up, + [192, 168, 1, 5], + )])); + let service = service_with( + source, + interfaces, + Arc::new(FakeListeners::default()), + Duration::from_secs(1), + ); + + let status = service + .configure_credentials(" reader ".to_string(), "secret".to_string()) + .await + .unwrap(); + assert_eq!(status.username.as_deref(), Some("reader")); + assert!(status.configured); + assert!(service + .inner + .credentials + .get() + .unwrap() + .password_verifier + .starts_with("$argon2id$")); + + let mut authenticated = config(8080); + authenticated.authentication_enabled = true; + assert_eq!( + service.start(authenticated).await.unwrap().state, + OpdsLifecycleState::Running + ); + assert_eq!( + service.clear_credentials().await.unwrap_err().code, + OpdsErrorCode::ConfigurationConflict + ); + service.stop().await; + assert!(!service.clear_credentials().await.unwrap().configured); + } + + #[tokio::test(flavor = "multi_thread")] + async fn authentication_requires_configured_credentials() { + let source = test_source(); + let service = service_with( + source, + Arc::new(FakeInterfaces::new(vec![lan( + OpdsInterfaceState::Up, + [192, 168, 1, 5], + )])), + Arc::new(FakeListeners::default()), + Duration::from_secs(1), + ); + let mut authenticated = config(8080); + authenticated.authentication_enabled = true; + + assert_eq!( + service.start(authenticated).await.unwrap_err().code, + OpdsErrorCode::CredentialsRequired + ); + assert_eq!(service.status().await.state, OpdsLifecycleState::Stopped); + } + + #[tokio::test(flavor = "multi_thread")] + async fn credential_validation_and_generation_have_defined_whitespace_behavior() { + let service = service_with( + missing_source(), + Arc::new(FakeInterfaces::new(Vec::new())), + Arc::new(FakeListeners::default()), + Duration::from_secs(1), + ); + + for username in ["", " ", "read:er", "read\ner"] { + assert_eq!( + service + .configure_credentials(username.to_string(), "secret".to_string()) + .await + .unwrap_err() + .code, + OpdsErrorCode::InvalidCredentials + ); + } + assert_eq!( + service + .configure_credentials("reader".to_string(), String::new()) + .await + .unwrap_err() + .code, + OpdsErrorCode::InvalidCredentials + ); + + service + .configure_credentials(" reader ".to_string(), " ".to_string()) + .await + .unwrap(); + assert_eq!( + service.credential_status().username.as_deref(), + Some("reader") + ); + + let generated = service + .generate_credentials("reader".to_string()) + .await + .unwrap(); + assert_eq!(generated.password.len(), 24); + assert!(!service + .inner + .credentials + .get() + .unwrap() + .password_verifier + .contains(&generated.password)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn invalid_port_missing_library_and_occupied_port_are_actionable() { + let interfaces = Arc::new(FakeInterfaces::new(vec![lan( + OpdsInterfaceState::Up, + [192, 168, 1, 5], + )])); + let listeners = Arc::new(FakeListeners::default()); + let missing = service_with( + missing_source(), + interfaces.clone(), + listeners.clone(), + Duration::from_secs(1), + ); + let status = missing.start(config(8080)).await.unwrap(); + assert_eq!(status.error.unwrap().code, OpdsErrorCode::LibraryNotReady); + + let source = test_source(); + let service = service_with( + source, + interfaces, + listeners.clone(), + Duration::from_secs(1), + ); + let invalid = service.start(config(70_000)).await.unwrap(); + assert_eq!(invalid.error.unwrap().code, OpdsErrorCode::InvalidPort); + + listeners.fail_bind.store(true, Ordering::Release); + let occupied = service.start(config(8080)).await.unwrap(); + assert_eq!(occupied.error.unwrap().code, OpdsErrorCode::PortUnavailable); + service.stop().await; + } + + #[tokio::test(flavor = "multi_thread")] + async fn listener_task_failure_is_observable_and_then_retried() { + let source = test_source(); + let interfaces = Arc::new(FakeInterfaces::new(vec![lan( + OpdsInterfaceState::Up, + [192, 168, 1, 5], + )])); + let listeners = Arc::new(FakeListeners::default()); + listeners.fail_task.store(true, Ordering::Release); + let service = service_with( + source, + interfaces, + listeners.clone(), + Duration::from_millis(50), + ); + + service.start(config(8080)).await.unwrap(); + let failed = wait_for_state(&service, OpdsLifecycleState::Error).await; + assert_eq!(failed.error.unwrap().code, OpdsErrorCode::ListenerFailed); + wait_for_state(&service, OpdsLifecycleState::Running).await; + assert_eq!(listeners.active.lock().unwrap().len(), 1); + service.stop().await; + assert!(listeners.active.lock().unwrap().is_empty()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn interface_enumeration_failure_is_actionable_and_retried() { + let source = test_source(); + let interfaces = Arc::new(FakeInterfaces::new(Vec::new())); + interfaces.set_error(io::ErrorKind::Other); + let listeners = Arc::new(FakeListeners::default()); + let service = service_with( + source, + interfaces.clone(), + listeners, + Duration::from_millis(10), + ); + + let failed = service.start(config(8080)).await.unwrap(); + assert_eq!( + failed.error.unwrap().code, + OpdsErrorCode::InterfaceEnumerationFailed + ); + interfaces.set(vec![lan(OpdsInterfaceState::Up, [192, 168, 1, 5])]); + wait_for_state(&service, OpdsLifecycleState::Running).await; + service.stop().await; + } + + #[tokio::test(flavor = "multi_thread")] + async fn starting_and_stopping_are_observable_during_blocked_dependencies() { + let source = test_source(); + let interfaces = Arc::new(BlockingInterfaces { + snapshot: vec![lan(OpdsInterfaceState::Up, [192, 168, 1, 5])], + entered: AtomicBool::new(false), + release: AtomicBool::new(false), + }); + let listeners = Arc::new(FakeListeners::default()); + let service = service_with( + source, + interfaces.clone(), + listeners.clone(), + Duration::from_secs(1), + ); + + let starting_service = service.clone(); + let start = tokio::spawn(async move { starting_service.start(config(8080)).await }); + while !interfaces.entered.load(Ordering::Acquire) { + tokio::task::yield_now().await; + } + assert_eq!(service.status().await.state, OpdsLifecycleState::Starting); + interfaces.release.store(true, Ordering::Release); + assert_eq!( + start.await.unwrap().unwrap().state, + OpdsLifecycleState::Running + ); + + listeners.hold_shutdown.store(true, Ordering::Release); + let stopping_service = service.clone(); + let stop = tokio::spawn(async move { stopping_service.stop().await }); + wait_for_state(&service, OpdsLifecycleState::Stopping).await; + listeners.hold_shutdown.store(false, Ordering::Release); + assert_eq!(stop.await.unwrap().state, OpdsLifecycleState::Stopped); + } + + #[tokio::test(flavor = "multi_thread")] + async fn real_listener_rejects_occupied_port_and_releases_it_on_stop() { + let occupied = StdTcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let address = occupied.local_addr().unwrap(); + let factory = TcpListenerFactory; + let tracker = Arc::new(ListenerTracker::default()); + let error = match factory.start( + address, + missing_source(), + OpdsBasicAuth::disabled(), + tracker.clone(), + ) { + Ok(_) => panic!("occupied listener unexpectedly bound"), + Err(error) => error, + }; + assert_eq!(error.kind(), io::ErrorKind::AddrInUse); + drop(occupied); + + let mut servers = BTreeMap::from([( + address, + factory + .start( + address, + missing_source(), + OpdsBasicAuth::disabled(), + tracker.clone(), + ) + .unwrap(), + )]); + shutdown_servers(&mut servers).await; + tracker.wait_until_empty().await; + assert!(StdTcpListener::bind(address).is_ok()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn forced_worker_abort_awaits_listener_cancellation_and_releases_port() { + let probe = StdTcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let port = probe.local_addr().unwrap().port(); + drop(probe); + let interfaces = Arc::new(HangingMonitorInterfaces { + snapshot: vec![lan(OpdsInterfaceState::Up, [127, 0, 0, 1])], + calls: AtomicUsize::new(0), + release: AtomicBool::new(false), + }); + let source = test_source(); + let service = service_with( + source, + interfaces.clone(), + Arc::new(TcpListenerFactory), + Duration::from_millis(5), + ); + assert_eq!( + service.start(config(u32::from(port))).await.unwrap().state, + OpdsLifecycleState::Running + ); + while interfaces.calls.load(Ordering::Acquire) < 2 { + tokio::task::yield_now().await; + } + + assert_eq!(service.stop().await.state, OpdsLifecycleState::Stopped); + assert!(StdTcpListener::bind((Ipv4Addr::LOCALHOST, port)).is_ok()); + interfaces.release.store(true, Ordering::Release); + } + + #[tokio::test(flavor = "multi_thread")] + async fn listener_completion_cannot_be_lost_between_count_check_and_wait() { + for _ in 0..1_000 { + let tracker = Arc::new(ListenerTracker::default()); + let completion = tracker.register(); + let waiter_tracker = tracker.clone(); + let waiter = tokio::spawn(async move { waiter_tracker.wait_until_empty().await }); + tokio::task::yield_now().await; + drop(completion); + tokio::time::timeout(Duration::from_millis(100), waiter) + .await + .expect("listener completion notification was lost") + .unwrap(); + } + } +} diff --git a/crates/citadel-server/Cargo.toml b/crates/citadel-server/Cargo.toml new file mode 100644 index 00000000..7c0b6c8a --- /dev/null +++ b/crates/citadel-server/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "citadel-server" +version = "0.1.0" +edition = "2021" +rust-version.workspace = true +description = "Headless Citadel OPDS server" + +[dependencies] +chrono = "0.4.31" +citadel-opds = { path = "../citadel-opds" } +libcalibre = { path = "../libcalibre" } +serde = { version = "1.0", features = ["derive"] } +tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "signal", "time"] } +toml = "0.9" + +[dev-dependencies] +base64 = "0.22" +nix = { version = "0.30", features = ["signal"] } +reqwest = { version = "0.12", features = ["blocking"] } +tempfile = "3.8" diff --git a/crates/citadel-server/example-config.toml b/crates/citadel-server/example-config.toml new file mode 100644 index 00000000..e18f4548 --- /dev/null +++ b/crates/citadel-server/example-config.toml @@ -0,0 +1,13 @@ +libraryPath = "/srv/calibre-library" +stateDirectory = "/var/lib/citadel" + +[sharing] +port = 8080 +authenticationEnabled = true + +[sharing.target] +type = "allLocalNetworks" + +[credentials] +username = "reader" +passwordEnvironment = "CITADEL_OPDS_PASSWORD" diff --git a/crates/citadel-server/src/lib.rs b/crates/citadel-server/src/lib.rs new file mode 100644 index 00000000..eab92e82 --- /dev/null +++ b/crates/citadel-server/src/lib.rs @@ -0,0 +1,388 @@ +use std::{ + ffi::OsString, + net::IpAddr, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, + time::Duration, +}; + +use chrono::NaiveDateTime; +use citadel_opds::{ + CatalogBookQuery, CatalogFacet, CatalogSource, OpdsErrorCode, OpdsLifecycleState, OpdsService, + OpdsServiceStatus, OpdsStartConfig, +}; +use libcalibre::{BookId, BookPage, CalibreError, Library, ResolvedBookAsset}; +use serde::Deserialize; + +const CREDENTIAL_FILENAME: &str = "opds-credentials.json"; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ServerConfig { + pub library_path: PathBuf, + pub state_directory: PathBuf, + pub sharing: OpdsStartConfig, + pub credentials: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CredentialInput { + pub username: String, + pub password_environment: String, +} + +pub struct CalibreCatalogSource { + library: Mutex, +} + +impl CalibreCatalogSource { + pub fn open(library_path: &Path) -> Result { + let library_path = library_path + .to_str() + .ok_or_else(|| "libraryPath must be valid UTF-8".to_string())?; + let database = libcalibre::util::get_db_path(library_path) + .ok_or_else(|| format!("libraryPath is not a Calibre library: {library_path}"))?; + let library = Library::new(database) + .map_err(|error| format!("could not open libraryPath: {error}"))?; + Ok(Self { + library: Mutex::new(library), + }) + } +} + +impl CatalogSource for CalibreCatalogSource { + fn active_library_id(&self) -> Result { + self.library + .lock() + .expect("server library mutex poisoned") + .library_uuid() + } + + fn book_page( + &self, + query: CatalogBookQuery, + ) -> Result<(String, Option, BookPage), CalibreError> { + let mut library = self.library.lock().expect("server library mutex poisoned"); + let library_id = library.library_uuid()?; + let updated_at = library.catalog_updated_at()?; + let page = match query.into_calibre() { + Some(query) => library.query_acquirable_books_with(query)?, + None => BookPage { + items: Vec::new(), + total: 0, + }, + }; + Ok((library_id, updated_at, page)) + } + + fn authors(&self) -> Result, CalibreError> { + self.library + .lock() + .expect("server library mutex poisoned") + .list_authors() + .map(|authors| { + authors + .into_iter() + .map(|author| CatalogFacet { + id: author.id.as_i32(), + title: author.name, + book_count: Some(author.book_count), + }) + .collect() + }) + } + + fn series(&self) -> Result, CalibreError> { + self.library + .lock() + .expect("server library mutex poisoned") + .list_series() + .map(|series| { + series + .into_iter() + .map(|series| CatalogFacet { + id: series.id, + title: series.name, + book_count: Some(series.book_count), + }) + .collect() + }) + } + + fn tags(&self) -> Result, CalibreError> { + self.library + .lock() + .expect("server library mutex poisoned") + .list_tags() + .map(|tags| { + tags.into_iter() + .map(|tag| CatalogFacet { + id: tag.id, + title: tag.name, + book_count: Some(tag.book_count), + }) + .collect() + }) + } + + fn book_file(&self, book_id: BookId, format: &str) -> Result { + self.library + .lock() + .expect("server library mutex poisoned") + .resolve_book_file(book_id, format) + } + + fn book_cover(&self, book_id: BookId) -> Result { + self.library + .lock() + .expect("server library mutex poisoned") + .resolve_book_cover(book_id) + } +} + +pub fn config_path_from_args(args: I) -> Result +where + I: IntoIterator, + S: Into, +{ + let mut args = args.into_iter().map(Into::into); + let program = args + .next() + .unwrap_or_else(|| OsString::from("citadel-server")); + let usage = || format!("usage: {} --config ", program.to_string_lossy()); + match (args.next(), args.next(), args.next()) { + (Some(flag), Some(path), None) if flag == "--config" => Ok(PathBuf::from(path)), + _ => Err(usage()), + } +} + +pub fn load_config(path: &Path) -> Result { + let bytes = std::fs::read(path) + .map_err(|error| format!("could not read config {}: {error}", path.display()))?; + let text = std::str::from_utf8(&bytes) + .map_err(|_| format!("config {} is not valid UTF-8", path.display()))?; + let config: ServerConfig = toml::from_str(text) + .map_err(|error| format!("could not parse config {}: {error}", path.display()))?; + validate_config(&config)?; + Ok(config) +} + +fn validate_config(config: &ServerConfig) -> Result<(), String> { + if !config.library_path.is_absolute() { + return Err("libraryPath must be absolute".to_string()); + } + if !config.state_directory.is_absolute() { + return Err("stateDirectory must be absolute".to_string()); + } + if config.sharing.port == 0 || config.sharing.port > u32::from(u16::MAX) { + return Err("sharing.port must be between 1 and 65535".to_string()); + } + if let citadel_opds::OpdsBindTarget::Addresses { addresses } = &config.sharing.target { + if addresses.is_empty() { + return Err("sharing.target.addresses must not be empty".to_string()); + } + for address in addresses { + let address = address + .parse::() + .map_err(|_| format!("invalid sharing target address: {address}"))?; + if address.is_unspecified() { + return Err("wildcard sharing target addresses are not allowed".to_string()); + } + } + } + if let Some(credentials) = &config.credentials { + if credentials.username.trim().is_empty() { + return Err("credentials.username must not be empty".to_string()); + } + if credentials.password_environment.trim().is_empty() { + return Err("credentials.passwordEnvironment must not be empty".to_string()); + } + } + Ok(()) +} + +pub async fn run(config: ServerConfig) -> Result<(), String> { + std::fs::create_dir_all(&config.state_directory).map_err(|error| { + format!( + "could not create stateDirectory {}: {error}", + config.state_directory.display() + ) + })?; + let source = Arc::new(CalibreCatalogSource::open(&config.library_path)?); + let service = OpdsService::new(source, config.state_directory.join(CREDENTIAL_FILENAME)) + .map_err(|error| format!("could not load OPDS credentials: {error}"))?; + + if let Some(credentials) = config.credentials { + let password = std::env::var_os(&credentials.password_environment).ok_or_else(|| { + format!( + "credential environment variable {} is not set", + credentials.password_environment + ) + })?; + let password = password.into_string().map_err(|_| { + format!( + "credential environment variable {} is not valid UTF-8", + credentials.password_environment + ) + })?; + service + .configure_credentials(credentials.username, password) + .await + .map_err(|error| error.message)?; + } + + let initial = service + .start(config.sharing) + .await + .map_err(|error| error.message)?; + log_status(&initial); + if is_fatal_startup(&initial) { + return Err(initial + .error + .map(|error| error.message) + .unwrap_or_else(|| "OPDS failed to start".to_string())); + } + + let mut last_status = initial; + let mut interval = tokio::time::interval(Duration::from_secs(2)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + interval.tick().await; + let mut shutdown = Box::pin(shutdown_signal()); + loop { + tokio::select! { + result = &mut shutdown => { + result?; + break; + } + _ = interval.tick() => { + let status = service.status().await; + if status != last_status { + log_status(&status); + last_status = status; + } + } + } + } + + let stopped = service.stop().await; + log_status(&stopped); + Ok(()) +} + +fn is_fatal_startup(status: &OpdsServiceStatus) -> bool { + matches!( + status.error.as_ref().map(|error| &error.code), + Some( + OpdsErrorCode::InvalidPort + | OpdsErrorCode::LibraryNotReady + | OpdsErrorCode::InvalidCredentials + | OpdsErrorCode::CredentialsRequired + | OpdsErrorCode::CredentialStorageFailed + | OpdsErrorCode::ConfigurationConflict + | OpdsErrorCode::Unexpected + ) + ) || status.state == OpdsLifecycleState::Stopped +} + +fn log_status(status: &OpdsServiceStatus) { + let urls = if status.urls.is_empty() { + "-".to_string() + } else { + status.urls.join(",") + }; + let error_code = status + .error + .as_ref() + .map(|error| format!("{:?}", error.code)) + .unwrap_or_else(|| "-".to_string()); + let message = status + .error + .as_ref() + .map(|error| format!("{:?}", error.message)) + .unwrap_or_else(|| "-".to_string()); + println!( + "citadel_server state={:?} port={} urls={} error={} message={}", + status.state, + status + .port + .map(|port| port.to_string()) + .unwrap_or_else(|| "-".to_string()), + urls, + error_code, + message + ); +} + +#[cfg(unix)] +async fn shutdown_signal() -> Result<(), String> { + use tokio::signal::unix::{signal, SignalKind}; + + let mut interrupt = signal(SignalKind::interrupt()) + .map_err(|error| format!("could not install SIGINT handler: {error}"))?; + let mut terminate = signal(SignalKind::terminate()) + .map_err(|error| format!("could not install SIGTERM handler: {error}"))?; + tokio::select! { + _ = interrupt.recv() => Ok(()), + _ = terminate.recv() => Ok(()), + } +} + +#[cfg(not(unix))] +async fn shutdown_signal() -> Result<(), String> { + tokio::signal::ctrl_c() + .await + .map_err(|error| format!("could not install Ctrl-C handler: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn arguments_require_one_explicit_config_path() { + assert_eq!( + config_path_from_args(["citadel-server", "--config", "/tmp/server.toml"]).unwrap(), + PathBuf::from("/tmp/server.toml") + ); + assert!(config_path_from_args(["citadel-server"]).is_err()); + assert!(config_path_from_args(["citadel-server", "server.toml"]).is_err()); + } + + #[test] + fn config_rejects_relative_paths_unknown_fields_and_invalid_ports() { + for text in [ + r#"libraryPath = "library" +stateDirectory = "/tmp/state" +[sharing] +port = 8080 +authenticationEnabled = false +[sharing.target] +type = "allLocalNetworks" +"#, + r#"libraryPath = "/tmp/library" +stateDirectory = "/tmp/state" +unknown = true +[sharing] +port = 8080 +authenticationEnabled = false +[sharing.target] +type = "allLocalNetworks" +"#, + r#"libraryPath = "/tmp/library" +stateDirectory = "/tmp/state" +[sharing] +port = 0 +authenticationEnabled = false +[sharing.target] +type = "allLocalNetworks" +"#, + ] { + let rejected = match toml::from_str::(text) { + Ok(config) => validate_config(&config).is_err(), + Err(_) => true, + }; + assert!(rejected); + } + } +} diff --git a/crates/citadel-server/src/main.rs b/crates/citadel-server/src/main.rs new file mode 100644 index 00000000..1a794e08 --- /dev/null +++ b/crates/citadel-server/src/main.rs @@ -0,0 +1,18 @@ +use std::process::ExitCode; + +#[tokio::main] +async fn main() -> ExitCode { + let result = match citadel_server::config_path_from_args(std::env::args_os()) + .and_then(|path| citadel_server::load_config(&path)) + { + Ok(config) => citadel_server::run(config).await, + Err(error) => Err(error), + }; + match result { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("citadel_server error={error}"); + ExitCode::FAILURE + } + } +} diff --git a/crates/citadel-server/tests/headless_process.rs b/crates/citadel-server/tests/headless_process.rs new file mode 100644 index 00000000..1c8e201a --- /dev/null +++ b/crates/citadel-server/tests/headless_process.rs @@ -0,0 +1,177 @@ +#![cfg(unix)] + +use std::{ + collections::HashMap, + io::{BufRead, BufReader}, + net::TcpListener, + process::{Command, Stdio}, + sync::mpsc, + time::{Duration, Instant}, +}; + +use base64::{engine::general_purpose::STANDARD, Engine}; +use libcalibre::{BookAdd, Library}; +use nix::{ + sys::signal::{kill, Signal}, + unistd::Pid, +}; + +#[test] +fn real_process_serves_authenticated_catalog_and_acquisition_then_stops() { + let directory = tempfile::tempdir().unwrap(); + let library_path = directory.path().join("library"); + let state_path = directory.path().join("state"); + std::fs::create_dir_all(&library_path).unwrap(); + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../libcalibre/tests/fixtures/empty_library/metadata.db"); + std::fs::copy(fixture, library_path.join("metadata.db")).unwrap(); + + let database = libcalibre::util::get_db_path(library_path.to_str().unwrap()).unwrap(); + let mut library = Library::new(database).unwrap(); + let source = directory.path().join("source.epub"); + std::fs::write(&source, b"headless-acquisition").unwrap(); + let book = library + .add_book(BookAdd { + title: "Headless Citadel".to_string(), + author_names: vec!["Citadel Test".to_string()], + tags: None, + series: None, + series_index: None, + publisher: None, + publication_date: None, + rating: None, + comments: None, + identifiers: HashMap::new(), + language: Some("eng".to_string()), + file_paths: vec![source], + }) + .unwrap(); + drop(library); + + let reservation = TcpListener::bind("0.0.0.0:0").unwrap(); + let port = reservation.local_addr().unwrap().port(); + drop(reservation); + let config_path = directory.path().join("server.toml"); + std::fs::write( + &config_path, + format!( + r#"libraryPath = "{}" +stateDirectory = "{}" + +[sharing] +port = {port} +authenticationEnabled = true + +[sharing.target] +type = "addresses" +addresses = ["127.0.0.1"] + +[credentials] +username = "reader" +passwordEnvironment = "CITADEL_TEST_OPDS_PASSWORD" +"#, + library_path.display(), + state_path.display() + ), + ) + .unwrap(); + + let mut child = Command::new(env!("CARGO_BIN_EXE_citadel-server")) + .args(["--config", config_path.to_str().unwrap()]) + .env("CITADEL_TEST_OPDS_PASSWORD", "secret") + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(); + let stdout = child.stdout.take().unwrap(); + let (lines_tx, lines_rx) = mpsc::channel(); + std::thread::spawn(move || { + for line in BufReader::new(stdout).lines().map_while(Result::ok) { + let _ = lines_tx.send(line); + } + }); + + let deadline = Instant::now() + Duration::from_secs(15); + let mut observed = Vec::new(); + let catalog_url = loop { + assert!( + Instant::now() < deadline, + "server did not start: {observed:?}" + ); + if let Ok(line) = lines_rx.recv_timeout(Duration::from_millis(250)) { + if let Some(urls) = line + .split_whitespace() + .find_map(|field| field.strip_prefix("urls=")) + { + if line.contains("state=Running") { + break urls.split(',').next().unwrap().to_string(); + } + } + observed.push(line); + } + if let Some(status) = child.try_wait().unwrap() { + panic!("server exited before listening with {status}: {observed:?}"); + } + }; + + let client = reqwest::blocking::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + assert_eq!(client.get(&catalog_url).send().unwrap().status(), 401); + let navigation = client + .get(&catalog_url) + .basic_auth("reader", Some("secret")) + .send() + .unwrap(); + assert!(navigation.status().is_success()); + assert!(navigation.text().unwrap().contains("All Books")); + + let origin = catalog_url.strip_suffix("/opds").unwrap(); + let feed = client + .get(format!("{origin}/opds/all")) + .basic_auth("reader", Some("secret")) + .send() + .unwrap(); + assert!(feed.status().is_success()); + assert!(feed.text().unwrap().contains("Headless Citadel")); + + let acquisition = client + .get(format!( + "{origin}/opds/books/{}/files/EPUB/book.epub", + book.id.as_i32() + )) + .header( + "Authorization", + format!("Basic {}", STANDARD.encode("reader:secret")), + ) + .send() + .unwrap(); + assert!(acquisition.status().is_success()); + assert_eq!( + acquisition.bytes().unwrap().as_ref(), + b"headless-acquisition" + ); + + kill(Pid::from_raw(child.id() as i32), Signal::SIGTERM).unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + let exit = loop { + if let Some(status) = child.try_wait().unwrap() { + break status; + } + if Instant::now() >= deadline { + child.kill().unwrap(); + panic!("server did not stop after SIGTERM"); + } + std::thread::sleep(Duration::from_millis(50)); + }; + assert!(exit.success()); + + observed.extend(lines_rx.try_iter()); + assert!(observed.iter().any(|line| line.contains("state=Stopped"))); + let credential_file = + std::fs::read_to_string(state_path.join("opds-credentials.json")).unwrap(); + assert!(credential_file.contains("$argon2id$")); + assert!(!credential_file.contains("secret")); +} diff --git a/crates/libcalibre/examples/bench_library_queries.rs b/crates/libcalibre/examples/bench_library_queries.rs index f9ed1aea..11ea2afd 100644 --- a/crates/libcalibre/examples/bench_library_queries.rs +++ b/crates/libcalibre/examples/bench_library_queries.rs @@ -317,7 +317,7 @@ fn run() -> Result<(), String> { return Err(format!("expected total {expected}, got {total}")); } let hydrated = &first_page.items[0]; - if hydrated.uuid.is_empty() || hydrated.authors.is_empty() { + if hydrated.uuid.as_deref().is_none_or(str::is_empty) || hydrated.authors.is_empty() { return Err("first page items are not fully hydrated".to_string()); } println!("Sanity check passed: total == {expected}, page is hydrated"); diff --git a/crates/libcalibre/src/assets/mod.rs b/crates/libcalibre/src/assets/mod.rs index 49395bc7..5aa75cf6 100644 --- a/crates/libcalibre/src/assets/mod.rs +++ b/crates/libcalibre/src/assets/mod.rs @@ -14,7 +14,7 @@ pub fn write(path: &Path, data: &[u8]) -> Result<(), CalibreError> { std::fs::write(path, data).map_err(|e| CalibreError::FileSystem(e.to_string())) } -pub fn asset_path(library_root: &String, book_path: &str, filename: &str) -> PathBuf { +pub fn asset_path(library_root: &str, book_path: &str, filename: &str) -> PathBuf { let library_path = Path::new(library_root); library_path.join(book_path).join(filename) } diff --git a/crates/libcalibre/src/error.rs b/crates/libcalibre/src/error.rs index cf1d708d..3edd232e 100644 --- a/crates/libcalibre/src/error.rs +++ b/crates/libcalibre/src/error.rs @@ -1,4 +1,4 @@ -use std::fmt; +use std::{fmt, path::PathBuf}; use crate::types::{AuthorId, BookId}; @@ -25,9 +25,21 @@ pub enum CalibreError { #[error("Book file not found for book {0} with format {1}")] BookFileNotFound(BookId, String), - /// Book file with given ID was not found. - #[error("Book file not found")] - BookCoverNotFound, + /// The book exists, but it has no cover entry. + #[error("Cover not found for book {0}")] + BookCoverNotFound(BookId), + + /// The database points at an asset which is absent from disk. + #[error("Asset file is missing: {0}")] + AssetFileMissing(PathBuf), + + /// The database-resolved asset escapes the canonical library root. + #[error("Asset path is outside the library root")] + AssetOutsideLibrary, + + /// A format identifier is empty or contains path syntax. + #[error("Invalid book format: {0}")] + InvalidBookFormat(String), #[error("Author cannot be deleted; they have associated books")] AuthorHasAssociatedBooks(Vec), diff --git a/crates/libcalibre/src/lib.rs b/crates/libcalibre/src/lib.rs index 9f426c42..5aabf3ef 100644 --- a/crates/libcalibre/src/lib.rs +++ b/crates/libcalibre/src/lib.rs @@ -19,9 +19,9 @@ pub mod util; pub use custom_columns::{CustomColumn, CustomColumnKind, CustomColumnSpec, CustomValue}; pub use error::CalibreError; pub use library::{ - Author as LibraryAuthor, AuthorAdd, AuthorUpdate, Book as LibraryBook, BookAdd, BookFileInfo, - BookIdentifier, BookPage, BookQuery, BookSortOrder, BookUpdate, Library, SeriesSummary, - TagSummary, + Author as LibraryAuthor, AuthorAdd, AuthorSummary, AuthorUpdate, Book as LibraryBook, BookAdd, + BookFileInfo, BookIdentifier, BookPage, BookQuery, BookSortOrder, BookUpdate, Library, + ResolvedBookAsset, SeriesSummary, TagSummary, }; pub use stats::{library_stats, LibraryStats}; pub use types::{AuthorId, BookFileId, BookId}; diff --git a/crates/libcalibre/src/library.rs b/crates/libcalibre/src/library.rs index 14310457..f1bfff3f 100644 --- a/crates/libcalibre/src/library.rs +++ b/crates/libcalibre/src/library.rs @@ -1,7 +1,7 @@ use std::{collections::HashMap, path::Path, path::PathBuf}; use chrono::{NaiveDate, NaiveDateTime}; -use diesel::{prelude::*, sql_query, RunQueryDsl, SqliteConnection}; +use diesel::{prelude::*, sql_query, OptionalExtension, RunQueryDsl, SqliteConnection}; use sanitise_file_name::sanitise; use crate::{ @@ -27,7 +27,7 @@ pub struct Book { /// Identifier unique only within this library pub id: BookId, /// Cross-library identifier - pub uuid: String, + pub uuid: Option, pub title: String, pub sortable_title: Option, pub authors: Vec, @@ -61,6 +61,8 @@ pub struct BookFileInfo { pub uncompressed_size: i32, } +pub use crate::operations::assets::ResolvedBookAsset; + #[derive(Clone, Debug)] pub struct BookIdentifier { pub id: i32, @@ -149,6 +151,10 @@ pub enum BookSortOrder { TitleDesc, AuthorAsc, AuthorDesc, + /// Most recently modified first, with book id as a stable tiebreaker. + UpdatedDesc, + /// Calibre series position first, then title and id for deterministic ties. + SeriesIndexAsc, } /// A paged, sorted, filtered book query. All filters compose (AND). @@ -164,9 +170,13 @@ pub struct BookQuery { pub author_id: Option, /// Only books linked to this series. pub series_id: Option, + /// Only books linked to this tag. + pub tag_id: Option, /// Exclude books marked read (filtered in SQL, so paging and totals stay /// correct). pub hide_read: bool, + /// Only books with at least one Calibre `data` row. + pub require_file: bool, pub sort: BookSortOrder, /// Maximum number of books to return. `None` returns all matches. pub limit: Option, @@ -194,12 +204,21 @@ pub struct SeriesSummary { pub book_count: i64, } +/// One author in the library with its linked-book count. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthorSummary { + pub id: AuthorId, + pub name: String, + pub book_count: i64, +} + /// One tag in the library. Returned by [`Library::list_tags`]; the full /// vocabulary feeds tag autocomplete in clients. #[derive(Clone, Debug, PartialEq, Eq)] pub struct TagSummary { pub id: i32, pub name: String, + pub book_count: i64, } impl Library { @@ -222,6 +241,41 @@ impl Library { &self.db_path.database_path } + pub fn library_uuid(&mut self) -> Result { + use crate::schema::library_id::dsl::{library_id, uuid}; + + library_id + .select(uuid) + .first::(&mut self.conn) + .optional()? + .ok_or_else(|| { + CalibreError::DatabaseIntegrity("library_id has no identity row".to_string()) + }) + } + + pub fn catalog_updated_at(&mut self) -> Result, CalibreError> { + use crate::schema::books::dsl::{books, last_modified}; + use diesel::dsl::max; + + let books_updated_at = books + .select(max(last_modified)) + .first::>(&mut self.conn) + .map_err(CalibreError::from)?; + let database_updated_at = [ + PathBuf::from(&self.db_path.database_path), + PathBuf::from(format!("{}-wal", self.db_path.database_path)), + ] + .into_iter() + .filter_map(|path| std::fs::metadata(path).ok()?.modified().ok()) + .map(|modified| chrono::DateTime::::from(modified).naive_utc()) + .max(); + + Ok(books_updated_at + .into_iter() + .chain(database_updated_at) + .max()) + } + // ========================================================================= // Books // ========================================================================= @@ -371,6 +425,7 @@ impl Library { } // 7. Generate metadata.opf from the freshly written DB state. + book_queries::touch(&mut self.conn, BookId(book_row.id))?; let _ = self.regenerate_metadata_opf(BookId(book_row.id)); self.get_book(BookId(book_row.id)) @@ -523,6 +578,23 @@ impl Library { ) } + pub fn resolve_book_file( + &mut self, + id: BookId, + format: &str, + ) -> Result { + operations::assets::resolve_book_file( + &self.db_path.library_path, + &mut self.conn, + id, + format, + ) + } + + pub fn resolve_book_cover(&mut self, id: BookId) -> Result { + operations::assets::resolve_book_cover(&self.db_path.library_path, &mut self.conn, id) + } + pub fn remove_book_file(&mut self, book_id: BookId, format: &str) -> Result<(), CalibreError> { operations::assets::remove_book_file( &self.db_path.library_path, @@ -591,7 +663,10 @@ impl Library { author_id: AuthorId, update: AuthorUpdate, ) -> Result { - operations::authors::update(&mut self.conn, author_id, update) + let book_ids = author_queries::find_books(&mut self.conn, author_id)?; + let author = operations::authors::update(&mut self.conn, author_id, update)?; + book_queries::touch_many(&mut self.conn, &book_ids)?; + Ok(author) } pub fn remove_author(&mut self, author_id: AuthorId) -> Result { @@ -612,27 +687,33 @@ impl Library { ) -> Result { use crate::schema::identifiers::dsl; - match existing_id { - Some(identifier_id) => { - diesel::update(dsl::identifiers.filter(dsl::id.eq(identifier_id))) - .set((dsl::type_.eq(&label), dsl::val.eq(&value))) - .returning(dsl::id) - .get_result::(&mut self.conn) - .map_err(CalibreError::from) - } - None => { - let lowercased_label = label.to_lowercase(); - diesel::insert_into(dsl::identifiers) - .values(( - dsl::book.eq(book_id.as_i32()), - dsl::type_.eq(lowercased_label), - dsl::val.eq(&value), - )) - .returning(dsl::id) - .get_result::(&mut self.conn) - .map_err(CalibreError::from) - } - } + self.conn.transaction(|conn| { + let identifier_id = match existing_id { + Some(identifier_id) => diesel::update( + dsl::identifiers + .filter(dsl::id.eq(identifier_id)) + .filter(dsl::book.eq(book_id.as_i32())), + ) + .set((dsl::type_.eq(&label), dsl::val.eq(&value))) + .returning(dsl::id) + .get_result::(conn) + .map_err(CalibreError::from), + None => { + let lowercased_label = label.to_lowercase(); + diesel::insert_into(dsl::identifiers) + .values(( + dsl::book.eq(book_id.as_i32()), + dsl::type_.eq(lowercased_label), + dsl::val.eq(&value), + )) + .returning(dsl::id) + .get_result::(conn) + .map_err(CalibreError::from) + } + }?; + book_queries::touch(conn, book_id)?; + Ok(identifier_id) + }) } pub fn delete_book_identifier( @@ -648,8 +729,9 @@ impl Library { .filter(dsl::id.eq(identifier_id)), ) .execute(&mut self.conn) - .map(|_| ()) - .map_err(CalibreError::from) + .map_err(CalibreError::from)?; + book_queries::touch(&mut self.conn, book_id)?; + Ok(()) } // ========================================================================= @@ -750,7 +832,14 @@ impl Library { } pub fn get_book_read_state(&mut self, book_id: BookId) -> Result { - let column = self.get_or_create_read_state_column()?; + let Some(column) = custom_columns::find_by_label_and_kind( + &mut self.conn, + "read", + &CustomColumnKind::Bool, + )? + else { + return Ok(false); + }; let value = custom_columns::get_value(&mut self.conn, &column, book_id)?; Ok(matches!(value, Some(CustomValue::Bool(true)))) } @@ -777,7 +866,14 @@ impl Library { return Ok(HashMap::new()); } - let column = self.get_or_create_read_state_column()?; + let Some(column) = custom_columns::find_by_label_and_kind( + &mut self.conn, + "read", + &CustomColumnKind::Bool, + )? + else { + return Ok(HashMap::new()); + }; let values = custom_columns::batch_get_values(&mut self.conn, &column, book_ids)?; Ok(values @@ -793,8 +889,9 @@ impl Library { pub fn randomize_library_uuid(&mut self) -> Result<(), CalibreError> { sql_query("UPDATE library_id SET uuid = uuid4()") .execute(&mut self.conn) - .map(|_| ()) - .map_err(CalibreError::from) + .map_err(CalibreError::from)?; + book_queries::touch_catalog(&mut self.conn)?; + Ok(()) } // ========================================================================= @@ -822,7 +919,9 @@ impl Library { .filter(|text| !text.is_empty()), author_id: query.author_id, series_id: query.series_id, + tag_id: query.tag_id, hide_read_column, + require_file: query.require_file, }; let total = book_queries::query_count(&mut self.conn, &filters)?; @@ -838,12 +937,106 @@ impl Library { Ok(BookPage { items, total }) } + /// Query books that have at least one safely resolvable acquisition file. + /// Candidate rows are scanned before paging so missing or escaping backing + /// files cannot create dead-only entries or pagination holes. + pub fn query_acquirable_books( + &mut self, + limit: i64, + offset: i64, + ) -> Result { + self.query_acquirable_books_with(BookQuery { + limit: Some(limit), + offset, + ..BookQuery::default() + }) + } + + /// Run a filtered, sorted catalog query while excluding books whose file + /// records cannot be resolved safely beneath the library root. Candidate + /// rows are scanned without hydrating books; only the selected page is + /// hydrated. + pub fn query_acquirable_books_with( + &mut self, + query: BookQuery, + ) -> Result { + let hide_read_column = if query.hide_read { + custom_columns::find_by_label_and_kind(&mut self.conn, "read", &CustomColumnKind::Bool)? + .map(|column| column.id) + } else { + None + }; + let filters = book_queries::BookPageFilters { + text: query + .text + .as_deref() + .map(str::trim) + .filter(|text| !text.is_empty()), + author_id: query.author_id, + series_id: query.series_id, + tag_id: query.tag_id, + hide_read_column, + require_file: true, + }; + let mut resolvable_ids = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for candidate in book_queries::acquisition_candidates(&mut self.conn, &filters, query.sort)? + { + let book_id = BookId(candidate.book_id); + if seen.contains(&book_id) { + continue; + } + if operations::assets::is_resolvable_book_file( + &self.db_path.library_path, + &candidate.book_path, + &candidate.name, + &candidate.format, + ) { + seen.insert(book_id); + resolvable_ids.push(book_id); + } + } + let total = i64::try_from(resolvable_ids.len()).unwrap_or(i64::MAX); + let start = usize::try_from(query.offset.max(0)).unwrap_or(usize::MAX); + let page_len = query + .limit + .map(|limit| usize::try_from(limit.max(0)).unwrap_or(usize::MAX)) + .unwrap_or(usize::MAX); + let book_ids = resolvable_ids + .into_iter() + .skip(start) + .take(page_len) + .collect(); + let mut items = self.get_books_with_read_states(book_ids)?; + for book in &mut items { + book.files.retain(|file| { + operations::assets::is_resolvable_book_file( + &self.db_path.library_path, + &book.book_dir_path, + &file.name, + &file.format, + ) + }); + book.has_cover = book.has_cover + && operations::assets::is_resolvable_book_cover( + &self.db_path.library_path, + &book.book_dir_path, + ); + } + Ok(BookPage { items, total }) + } + /// List every series in the library with its linked-book count, sorted /// by name. The returned ids feed [`BookQuery::series_id`]. pub fn list_series(&mut self) -> Result, CalibreError> { crate::queries::series::list_with_book_counts(&mut self.conn) } + /// List authors with linked-book counts, sorted case-insensitively. + pub fn list_authors(&mut self) -> Result, CalibreError> { + author_queries::list_with_book_counts(&mut self.conn) + } + /// List every tag in the library (the whole vocabulary, including tags /// no longer linked to any book), sorted case-insensitively by name. pub fn list_tags(&mut self) -> Result, CalibreError> { diff --git a/crates/libcalibre/src/operations/assets.rs b/crates/libcalibre/src/operations/assets.rs index a0a99145..fbea1cee 100644 --- a/crates/libcalibre/src/operations/assets.rs +++ b/crates/libcalibre/src/operations/assets.rs @@ -1,3 +1,5 @@ +use std::path::{Path, PathBuf}; + use diesel::{Connection, SqliteConnection}; use crate::{ @@ -7,53 +9,144 @@ use crate::{ CalibreError, UpdateBookData, }; -pub fn get_book_cover( - library_root: &String, +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ResolvedBookAsset { + canonical_path: PathBuf, + download_name: String, + format: String, +} + +impl ResolvedBookAsset { + pub fn canonical_path(&self) -> &Path { + &self.canonical_path + } + + pub fn download_name(&self) -> &str { + &self.download_name + } + + pub fn format(&self) -> &str { + &self.format + } +} + +fn canonical_asset(library_root: &str, path: PathBuf) -> Result { + let canonical_root = Path::new(library_root).canonicalize()?; + let canonical_path = match path.canonicalize() { + Ok(path) => path, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(CalibreError::AssetFileMissing(path)); + } + Err(error) => return Err(CalibreError::IoError(error)), + }; + + if !canonical_path.starts_with(canonical_root) { + return Err(CalibreError::AssetOutsideLibrary); + } + + Ok(canonical_path) +} + +fn normalized_format(file_format: &str) -> Result { + let trimmed = file_format.trim(); + let normalized = trimmed + .strip_prefix('.') + .unwrap_or(trimmed) + .to_ascii_uppercase(); + if normalized.is_empty() || !normalized.bytes().all(|byte| byte.is_ascii_alphanumeric()) { + return Err(CalibreError::InvalidBookFormat(file_format.to_string())); + } + Ok(normalized) +} + +pub(crate) fn is_resolvable_book_file( + library_root: &str, + book_path: &str, + file_name: &str, + file_format: &str, +) -> bool { + let Ok(format) = normalized_format(file_format) else { + return false; + }; + let download_name = format!("{}.{}", file_name, format.to_ascii_lowercase()); + let path = assets::asset_path(library_root, book_path, &download_name); + canonical_asset(library_root, path).is_ok() +} + +pub(crate) fn is_resolvable_book_cover(library_root: &str, book_path: &str) -> bool { + let path = assets::asset_path(library_root, book_path, COVER_FILENAME); + canonical_asset(library_root, path).is_ok() +} + +pub fn resolve_book_cover( + library_root: &str, conn: &mut SqliteConnection, book_id: BookId, -) -> Result, CalibreError> { +) -> Result { let book = books::get(conn, book_id)?.ok_or(CalibreError::BookNotFound(book_id))?; - let file_path = assets::asset_path(library_root, &book.path, COVER_FILENAME); + if book.has_cover != Some(true) { + return Err(CalibreError::BookCoverNotFound(book_id)); + } - assets::read(&file_path) + let path = assets::asset_path(library_root, &book.path, COVER_FILENAME); + Ok(ResolvedBookAsset { + canonical_path: canonical_asset(library_root, path)?, + download_name: COVER_FILENAME.to_string(), + format: "JPG".to_string(), + }) } -pub fn get_book_file_path( - library_root: &String, +pub fn resolve_book_file( + library_root: &str, conn: &mut SqliteConnection, book_id: BookId, file_format: &str, -) -> Result { +) -> Result { let book = books::get(conn, book_id)?.ok_or(CalibreError::BookNotFound(book_id))?; - let file = book_files::find_by_book_and_format(conn, book_id, file_format.to_string())?; + let requested_format = normalized_format(file_format)?; + let file = book_files::find_by_book_and_format(conn, book_id, requested_format.clone())? + .ok_or_else(|| CalibreError::BookFileNotFound(book_id, requested_format.clone()))?; + let stored_format = file.format.to_ascii_uppercase(); + let download_name = format!("{}.{}", file.name, stored_format.to_ascii_lowercase()); + let path = assets::asset_path(library_root, &book.path, &download_name); + + Ok(ResolvedBookAsset { + canonical_path: canonical_asset(library_root, path)?, + download_name, + format: stored_format, + }) +} - match file { - Some(_) => { - let book_filename = filename(book.path.clone(), file_format.to_string()); - let file_path = assets::asset_path(library_root, &book.path, &book_filename); - Ok(file_path) - } - None => { - return Err(CalibreError::BookFileNotFound( - book_id, - file_format.to_string(), - )) - } - } +pub fn get_book_cover( + library_root: &str, + conn: &mut SqliteConnection, + book_id: BookId, +) -> Result, CalibreError> { + let asset = resolve_book_cover(library_root, conn, book_id)?; + assets::read(asset.canonical_path()) +} + +pub fn get_book_file_path( + library_root: &str, + conn: &mut SqliteConnection, + book_id: BookId, + file_format: &str, +) -> Result { + resolve_book_file(library_root, conn, book_id, file_format).map(|asset| asset.canonical_path) } pub fn get_book_file( - library_root: &String, + library_root: &str, conn: &mut SqliteConnection, book_id: BookId, file_format: &str, ) -> Result, CalibreError> { let file_path = get_book_file_path(library_root, conn, book_id, file_format)?; - return assets::read(&file_path); + assets::read(&file_path) } pub fn add_book_file_from_bytes( - library_root: &String, + library_root: &str, conn: &mut SqliteConnection, book_id: BookId, file_format: String, @@ -73,7 +166,10 @@ pub fn add_book_file_from_bytes( }; match book_files::create(conn, new_file) { - Ok(_) => Ok(()), + Ok(_) => { + books::touch(conn, book_id)?; + Ok(()) + } Err(e) => { // If database entry fails, remove the written file let _ = std::fs::remove_file(&file_path); @@ -83,7 +179,7 @@ pub fn add_book_file_from_bytes( } pub fn add_book_file_from_path( - library_root: &String, + library_root: &str, conn: &mut SqliteConnection, book_id: BookId, file_format: String, @@ -107,7 +203,10 @@ pub fn add_book_file_from_path( }; match book_files::create(conn, new_file) { - Ok(_) => Ok(()), + Ok(_) => { + books::touch(conn, book_id)?; + Ok(()) + } Err(e) => { // If database entry fails, remove the copied file let _ = std::fs::remove_file(&dest_path); @@ -117,7 +216,7 @@ pub fn add_book_file_from_path( } pub fn set_book_cover( - library_root: &String, + library_root: &str, conn: &mut SqliteConnection, book_id: BookId, cover_data: Vec, @@ -133,12 +232,13 @@ pub fn set_book_cover( ..Default::default() }; books::update(conn, book_id, update)?; + books::touch(conn, book_id)?; Ok(()) }) } pub fn remove_book_file( - library_root: &String, + library_root: &str, conn: &mut SqliteConnection, book_id: BookId, file_format: &str, @@ -168,6 +268,7 @@ pub fn remove_book_file( eprintln!("WARNING: Failed to delete file {:?}: {}", file_path, e); } } + books::touch(conn, book_id)?; } Ok(()) @@ -175,7 +276,7 @@ pub fn remove_book_file( } pub(crate) fn delete_entire_book( - library_root: &String, + library_root: &str, conn: &mut SqliteConnection, book_id: BookId, book_path: &str, @@ -192,6 +293,7 @@ pub(crate) fn delete_entire_book( } books::delete(conn, book_id)?; + books::touch_catalog(conn)?; let book_dir = std::path::Path::new(library_root).join(book_path); match std::fs::remove_dir_all(&book_dir) { @@ -212,7 +314,7 @@ fn filename(book_path: String, file_format: String) -> String { let base_name = book_path; if ext.is_empty() { - format!("{}", base_name) + base_name } else { format!("{}.{}", base_name, ext) } diff --git a/crates/libcalibre/src/operations/books.rs b/crates/libcalibre/src/operations/books.rs index 0a96653f..3085b883 100644 --- a/crates/libcalibre/src/operations/books.rs +++ b/crates/libcalibre/src/operations/books.rs @@ -152,6 +152,7 @@ pub fn update_book( } } + books::touch(conn, book_id)?; Ok(()) }) } @@ -205,9 +206,10 @@ pub fn get_book(conn: &mut SqliteConnection, book_id: BookId) -> Result Result, CalibreError> { + #[derive(QueryableByName)] + struct AuthorCountRow { + #[diesel(sql_type = Integer)] + id: i32, + #[diesel(sql_type = Text)] + name: String, + #[diesel(sql_type = BigInt)] + book_count: i64, + } + + let rows: Vec = sql_query( + "SELECT a.id AS id, a.name AS name, COUNT(bal.book) AS book_count + FROM authors a + LEFT JOIN books_authors_link bal ON bal.author = a.id + GROUP BY a.id, a.name + ORDER BY a.name COLLATE NOCASE, a.name, a.id", + ) + .load(conn) + .map_err(CalibreError::from)?; + + Ok(rows + .into_iter() + .map(|row| AuthorSummary { + id: AuthorId(row.id), + name: row.name, + book_count: row.book_count, + }) + .collect()) +} pub(crate) fn get( conn: &mut SqliteConnection, diff --git a/crates/libcalibre/src/queries/books.rs b/crates/libcalibre/src/queries/books.rs index 1454bca9..b933ae65 100644 --- a/crates/libcalibre/src/queries/books.rs +++ b/crates/libcalibre/src/queries/books.rs @@ -103,9 +103,11 @@ pub(crate) struct BookPageFilters<'a> { pub text: Option<&'a str>, pub author_id: Option, pub series_id: Option, + pub tag_id: Option, /// Id of the `read` bool custom column. When set, books marked read are /// excluded. pub hide_read_column: Option, + pub require_file: bool, } #[derive(QueryableByName)] @@ -120,6 +122,18 @@ struct CountRow { total: i64, } +#[derive(QueryableByName)] +pub(crate) struct AcquisitionCandidate { + #[diesel(sql_type = Integer)] + pub book_id: i32, + #[diesel(sql_type = Text)] + pub book_path: String, + #[diesel(sql_type = Text)] + pub format: String, + #[diesel(sql_type = Text)] + pub name: String, +} + fn like_pattern(text: &str) -> String { let escaped = text .replace('\\', "\\\\") @@ -162,6 +176,13 @@ fn filter_where_sql(filters: &BookPageFilters) -> String { )); } + if let Some(tag_id) = filters.tag_id { + clauses.push(format!( + "EXISTS (SELECT 1 FROM books_tags_link btl2 \ + WHERE btl2.book = books.id AND btl2.tag = {tag_id})" + )); + } + if let Some(n) = filters.hide_read_column { clauses.push(format!( "NOT EXISTS (SELECT 1 FROM custom_column_{n} cc \ @@ -169,6 +190,10 @@ fn filter_where_sql(filters: &BookPageFilters) -> String { )); } + if filters.require_file { + clauses.push("EXISTS (SELECT 1 FROM data d WHERE d.book = books.id)".to_string()); + } + clauses.join(" AND ") } @@ -185,6 +210,39 @@ fn order_by_sql(sort: BookSortOrder) -> String { BookSortOrder::TitleDesc => "books.sort DESC, books.id DESC".to_string(), BookSortOrder::AuthorAsc => format!("{AUTHOR_SORT} ASC, books.id ASC"), BookSortOrder::AuthorDesc => format!("{AUTHOR_SORT} DESC, books.id DESC"), + BookSortOrder::UpdatedDesc => "books.last_modified DESC, books.id DESC".to_string(), + BookSortOrder::SeriesIndexAsc => { + "books.series_index IS NULL ASC, books.series_index ASC, books.sort ASC, books.id ASC" + .to_string() + } + } +} + +pub(crate) fn acquisition_candidates( + conn: &mut SqliteConnection, + filters: &BookPageFilters, + sort: BookSortOrder, +) -> Result, CalibreError> { + let where_sql = filter_where_sql(filters); + let order_sql = order_by_sql(sort); + let sql = format!( + "SELECT books.id AS book_id, books.path AS book_path, \ + data.format AS format, data.name AS name \ + FROM books JOIN data ON data.book = books.id \ + WHERE {where_sql} ORDER BY {order_sql}, data.id ASC" + ); + + match filters.text { + Some(text) => { + let pattern = like_pattern(text); + sql_query(sql) + .bind::(&pattern) + .bind::(&pattern) + .bind::(&pattern) + .load(conn) + .map_err(CalibreError::from) + } + None => sql_query(sql).load(conn).map_err(CalibreError::from), } } @@ -271,6 +329,65 @@ pub(crate) fn update( .map_err(CalibreError::from) } +pub(crate) fn touch(conn: &mut SqliteConnection, book_id: BookId) -> Result { + use crate::schema::books::dsl::{books, id, last_modified}; + + let previous = books + .filter(id.eq(book_id.as_i32())) + .select(last_modified) + .first::(conn)?; + let updated_at = std::cmp::max( + chrono::Utc::now().naive_utc(), + previous + chrono::TimeDelta::microseconds(1), + ); + diesel::update(books.filter(id.eq(book_id.as_i32()))) + .set(last_modified.eq(updated_at)) + .execute(conn) + .map_err(CalibreError::from) +} + +pub(crate) fn touch_many( + conn: &mut SqliteConnection, + book_ids: &[BookId], +) -> Result { + use crate::schema::books::dsl::{books, id, last_modified}; + + if book_ids.is_empty() { + return Ok(0); + } + let previous = books + .filter(id.eq_any(book_ids.iter().map(|book_id| book_id.as_i32()))) + .select(diesel::dsl::max(last_modified)) + .first::>(conn)?; + let updated_at = previous + .map(|previous| { + std::cmp::max( + chrono::Utc::now().naive_utc(), + previous + chrono::TimeDelta::microseconds(1), + ) + }) + .unwrap_or_else(|| chrono::Utc::now().naive_utc()); + let ids = book_ids.iter().map(|book_id| book_id.as_i32()); + diesel::update(books.filter(id.eq_any(ids))) + .set(last_modified.eq(updated_at)) + .execute(conn) + .map_err(CalibreError::from) +} + +pub(crate) fn touch_catalog(conn: &mut SqliteConnection) -> Result { + use crate::schema::books::dsl::{books, id}; + + match books + .select(id) + .order(id.asc()) + .first::(conn) + .optional()? + { + Some(book_id) => touch(conn, BookId(book_id)), + None => Ok(0), + } +} + pub(crate) fn delete(conn: &mut SqliteConnection, book_id: BookId) -> Result { use crate::schema::books::dsl::*; diff --git a/crates/libcalibre/src/queries/tags.rs b/crates/libcalibre/src/queries/tags.rs index a985ec6f..a58330f6 100644 --- a/crates/libcalibre/src/queries/tags.rs +++ b/crates/libcalibre/src/queries/tags.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use diesel::prelude::*; use diesel::sql_query; -use diesel::sql_types::{Integer, Text}; +use diesel::sql_types::{BigInt, Integer, Text}; use diesel::{QueryDsl, RunQueryDsl, SqliteConnection}; use crate::entities::tag::NewTag; @@ -13,20 +13,34 @@ use crate::{CalibreError, Tag}; /// Every tag in the library, sorted case-insensitively by name (with the /// raw name as a stable tiebreak, matching [`find_for_book`]). pub(crate) fn list_all(conn: &mut SqliteConnection) -> Result, CalibreError> { - use crate::schema::tags::dsl::*; + #[derive(QueryableByName)] + struct TagCountRow { + #[diesel(sql_type = Integer)] + id: i32, + #[diesel(sql_type = Text)] + name: String, + #[diesel(sql_type = BigInt)] + book_count: i64, + } - tags.select(Tag::as_select()) - .order_by(diesel::dsl::sql::("LOWER(name), name")) - .load(conn) - .map(|rows: Vec| { - rows.into_iter() - .map(|tag| TagSummary { - id: tag.id, - name: tag.name, - }) - .collect() + let rows: Vec = sql_query( + "SELECT t.id AS id, t.name AS name, COUNT(btl.book) AS book_count + FROM tags t + LEFT JOIN books_tags_link btl ON btl.tag = t.id + GROUP BY t.id, t.name + ORDER BY t.name COLLATE NOCASE, t.name, t.id", + ) + .load(conn) + .map_err(CalibreError::from)?; + + Ok(rows + .into_iter() + .map(|row| TagSummary { + id: row.id, + name: row.name, + book_count: row.book_count, }) - .map_err(CalibreError::from) + .collect()) } pub(crate) fn find_by_name_case_insensitive( diff --git a/crates/libcalibre/tests/asset_resolution_test.rs b/crates/libcalibre/tests/asset_resolution_test.rs new file mode 100644 index 00000000..482c0b34 --- /dev/null +++ b/crates/libcalibre/tests/asset_resolution_test.rs @@ -0,0 +1,179 @@ +mod common; + +use std::fs; + +use common::{setup_with_library, standard_test_book}; +use libcalibre::{error::CalibreError, util::get_db_path, BookId, Library}; +use rusqlite::{params, Connection}; + +fn reopen(temp: &tempfile::TempDir) -> Library { + let path = get_db_path(temp.path().to_str().unwrap()).unwrap(); + Library::new(path).unwrap() +} + +fn add_file_row( + temp: &tempfile::TempDir, + book_id: BookId, + book_path: &str, + name: &str, + format: &str, + bytes: &[u8], +) { + let db = Connection::open(temp.path().join("metadata.db")).unwrap(); + db.execute( + "INSERT INTO data (book, format, uncompressed_size, name) VALUES (?1, ?2, ?3, ?4)", + params![book_id.as_i32(), format, bytes.len() as i32, name], + ) + .unwrap(); + let dir = temp.path().join(book_path); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join(format!("{name}.{}", format.to_ascii_lowercase())), + bytes, + ) + .unwrap(); +} + +#[test] +fn resolves_the_matching_data_name_and_normalizes_format() { + let (temp, mut library) = setup_with_library(); + let book = library.add_book(standard_test_book()).unwrap(); + drop(library); + add_file_row( + &temp, + book.id, + &book.book_dir_path, + "Authoritative Calibre Name", + "EPUB", + b"epub", + ); + + let mut library = reopen(&temp); + let asset = library.resolve_book_file(book.id, " .ePuB ").unwrap(); + assert_eq!(asset.download_name(), "Authoritative Calibre Name.epub"); + assert_eq!(asset.format(), "EPUB"); + assert!(asset.canonical_path().is_absolute()); + assert_eq!(fs::read(asset.canonical_path()).unwrap(), b"epub"); +} + +#[test] +fn distinguishes_missing_book_format_and_backing_file() { + let (temp, mut library) = setup_with_library(); + let book = library.add_book(standard_test_book()).unwrap(); + drop(library); + + let mut library = reopen(&temp); + assert!(matches!( + library.resolve_book_file(BookId::from(999_999), "epub"), + Err(CalibreError::BookNotFound(_)) + )); + assert!(matches!( + library.resolve_book_file(book.id, "epub"), + Err(CalibreError::BookFileNotFound(_, ref format)) if format == "EPUB" + )); + drop(library); + + add_file_row(&temp, book.id, &book.book_dir_path, "Gone", "EPUB", b"gone"); + fs::remove_file(temp.path().join(&book.book_dir_path).join("Gone.epub")).unwrap(); + let mut library = reopen(&temp); + assert!(matches!( + library.resolve_book_file(book.id, "epub"), + Err(CalibreError::AssetFileMissing(_)) + )); +} + +#[test] +fn rejects_format_path_syntax_before_filesystem_resolution() { + let (_temp, mut library) = setup_with_library(); + let book = library.add_book(standard_test_book()).unwrap(); + assert!(matches!( + library.resolve_book_file(book.id, "../epub"), + Err(CalibreError::InvalidBookFormat(_)) + )); +} + +#[test] +fn distinguishes_coverless_book_from_missing_cover_file() { + let (temp, mut library) = setup_with_library(); + let book = library.add_book(standard_test_book()).unwrap(); + assert!(matches!( + library.resolve_book_cover(book.id), + Err(CalibreError::BookCoverNotFound(id)) if id == book.id + )); + library.set_book_cover(book.id, b"cover".to_vec()).unwrap(); + let cover_path = library.resolve_book_cover(book.id).unwrap(); + assert_eq!(cover_path.download_name(), "cover.jpg"); + assert_eq!(cover_path.format(), "JPG"); + fs::remove_file(cover_path.canonical_path()).unwrap(); + drop(library); + let mut library = reopen(&temp); + assert!(matches!( + library.resolve_book_cover(book.id), + Err(CalibreError::AssetFileMissing(_)) + )); +} + +#[test] +fn rejects_a_malformed_database_book_path_that_escapes_the_library() { + let (temp, mut library) = setup_with_library(); + let book = library.add_book(standard_test_book()).unwrap(); + drop(library); + + let outside_dir = temp + .path() + .parent() + .unwrap() + .join(format!("citadel-escaped-book-{}", std::process::id())); + fs::create_dir_all(&outside_dir).unwrap(); + fs::write(outside_dir.join("Outside.epub"), b"outside").unwrap(); + let escaped_book_path = format!("../{}", outside_dir.file_name().unwrap().to_string_lossy()); + let db = Connection::open(temp.path().join("metadata.db")).unwrap(); + db.execute_batch("DROP TRIGGER IF EXISTS books_update_trg") + .unwrap(); + db.execute( + "UPDATE books SET path = ?1 WHERE id = ?2", + params![escaped_book_path, book.id.as_i32()], + ) + .unwrap(); + db.execute( + "INSERT INTO data (book, format, uncompressed_size, name) VALUES (?1, 'EPUB', 7, 'Outside')", + [book.id.as_i32()], + ) + .unwrap(); + drop(db); + + let mut library = reopen(&temp); + assert!(matches!( + library.resolve_book_file(book.id, "epub"), + Err(CalibreError::AssetOutsideLibrary) + )); + fs::remove_dir_all(outside_dir).unwrap(); +} + +#[cfg(unix)] +#[test] +fn rejects_a_symlink_that_escapes_the_library() { + use std::os::unix::fs::symlink; + + let (temp, mut library) = setup_with_library(); + let book = library.add_book(standard_test_book()).unwrap(); + drop(library); + add_file_row( + &temp, + book.id, + &book.book_dir_path, + "Escaped", + "EPUB", + b"placeholder", + ); + let asset_path = temp.path().join(&book.book_dir_path).join("Escaped.epub"); + fs::remove_file(&asset_path).unwrap(); + let outside = tempfile::NamedTempFile::new().unwrap(); + symlink(outside.path(), asset_path).unwrap(); + + let mut library = reopen(&temp); + assert!(matches!( + library.resolve_book_file(book.id, "epub"), + Err(CalibreError::AssetOutsideLibrary) + )); +} diff --git a/crates/libcalibre/tests/books_api_test.rs b/crates/libcalibre/tests/books_api_test.rs index 9a4a1335..704990ee 100644 --- a/crates/libcalibre/tests/books_api_test.rs +++ b/crates/libcalibre/tests/books_api_test.rs @@ -53,7 +53,7 @@ fn test_add_book() { let book = result.unwrap(); assert_eq!(book.title, "Test Book"); - assert!(!book.uuid.is_empty()); + assert!(book.uuid.as_deref().is_some_and(|uuid| !uuid.is_empty())); } #[test] diff --git a/crates/libcalibre/tests/query_test.rs b/crates/libcalibre/tests/query_test.rs index 0368b671..1031471b 100644 --- a/crates/libcalibre/tests/query_test.rs +++ b/crates/libcalibre/tests/query_test.rs @@ -51,6 +51,15 @@ fn series_id_by_name(lib: &mut Library, name: &str) -> i32 { row.id } +fn tag_id_by_name(lib: &mut Library, name: &str) -> i32 { + lib.list_tags() + .unwrap() + .into_iter() + .find(|tag| tag.name == name) + .expect("tag not found") + .id +} + // ============================================================================= // Paging // ============================================================================= @@ -194,6 +203,63 @@ fn test_sort_author_asc() { ); } +#[test] +fn test_sort_updated_desc_uses_id_as_stable_tiebreaker() { + let (_temp, mut lib) = setup_with_library(); + let older = lib.add_book(book("Older", &[])).unwrap(); + let newer_first = lib.add_book(book("Newer First", &[])).unwrap(); + let newer_second = lib.add_book(book("Newer Second", &[])).unwrap(); + + let mut conn = libcalibre::persistence::establish_connection(lib.database_path()).unwrap(); + sql_query("UPDATE books SET last_modified = '2024-01-01 00:00:00' WHERE id = ?") + .bind::(older.id.as_i32()) + .execute(&mut conn) + .unwrap(); + sql_query("UPDATE books SET last_modified = '2024-02-01 00:00:00' WHERE id IN (?, ?)") + .bind::(newer_first.id.as_i32()) + .bind::(newer_second.id.as_i32()) + .execute(&mut conn) + .unwrap(); + + let page = query( + &mut lib, + BookQuery { + sort: BookSortOrder::UpdatedDesc, + ..BookQuery::default() + }, + ); + assert_eq!(titles(&page), ["Newer Second", "Newer First", "Older"]); +} + +#[test] +fn test_sort_series_index_handles_fractions_and_stable_ties() { + let (_temp, mut lib) = setup_with_library(); + for (title, index) in [ + ("Second B", 2.0), + ("First", 1.0), + ("Second A", 2.0), + ("Bridge", 1.5), + ] { + lib.add_book(BookAdd { + series: Some("Saga".to_string()), + series_index: Some(index), + ..book(title, &[]) + }) + .unwrap(); + } + let saga = series_id_by_name(&mut lib, "Saga"); + + let page = query( + &mut lib, + BookQuery { + series_id: Some(saga), + sort: BookSortOrder::SeriesIndexAsc, + ..BookQuery::default() + }, + ); + assert_eq!(titles(&page), ["First", "Bridge", "Second A", "Second B"]); +} + #[test] fn test_sort_author_desc() { let (_temp, mut lib) = setup_with_library(); @@ -460,6 +526,11 @@ fn test_hide_read_with_no_read_column_matches_all() { ); assert_eq!(page.items.len(), 1); assert_eq!(page.total, 1); + assert!(lib + .custom_columns() + .unwrap() + .iter() + .all(|column| column.label != "read")); } #[test] @@ -536,6 +607,92 @@ fn test_page_items_are_hydrated() { // Tag listing // ============================================================================= +#[test] +fn test_list_authors_returns_counts_sorted_case_insensitively() { + let (_temp, mut lib) = setup_with_library(); + lib.add_book(book("First", &["zebra", "Alpha"])).unwrap(); + lib.add_book(book("Second", &["zebra"])).unwrap(); + + let authors = lib.list_authors().unwrap(); + let summary: Vec<_> = authors + .iter() + .map(|author| (author.name.as_str(), author.book_count)) + .collect(); + assert_eq!(summary, [("Alpha", 1), ("zebra", 2)]); +} + +#[test] +fn test_tag_filter_composes_with_paging_and_totals() { + let (_temp, mut lib) = setup_with_library(); + + for title in ["Alpha", "Beta", "Gamma"] { + lib.add_book(BookAdd { + tags: Some(vec!["Selected".to_string()]), + ..book(title, &[]) + }) + .unwrap(); + } + lib.add_book(BookAdd { + tags: Some(vec!["Other".to_string()]), + ..book("Excluded", &[]) + }) + .unwrap(); + + let selected_tag = tag_id_by_name(&mut lib, "Selected"); + let page = query( + &mut lib, + BookQuery { + tag_id: Some(selected_tag), + limit: Some(2), + offset: 1, + ..BookQuery::default() + }, + ); + assert_eq!(titles(&page), ["Beta", "Gamma"]); + assert_eq!(page.total, 3); +} + +#[test] +fn test_acquirable_query_composes_filters_and_excludes_fileless_books() { + let (temp, mut lib) = setup_with_library(); + let source = temp.path().join("source.epub"); + std::fs::write(&source, b"epub").unwrap(); + + for title in ["Alpha Match", "Beta Match"] { + lib.add_book(BookAdd { + tags: Some(vec!["Selected".to_string()]), + file_paths: vec![source.clone()], + ..book(title, &["Writer"]) + }) + .unwrap(); + } + lib.add_book(BookAdd { + tags: Some(vec!["Selected".to_string()]), + ..book("Fileless Match", &["Writer"]) + }) + .unwrap(); + lib.add_book(BookAdd { + tags: Some(vec!["Other".to_string()]), + file_paths: vec![source], + ..book("Other Match", &["Writer"]) + }) + .unwrap(); + + let selected_tag = tag_id_by_name(&mut lib, "Selected"); + let page = lib + .query_acquirable_books_with(BookQuery { + text: Some("match".to_string()), + tag_id: Some(selected_tag), + limit: Some(1), + offset: 1, + ..BookQuery::default() + }) + .unwrap(); + assert_eq!(titles(&page), ["Beta Match"]); + assert_eq!(page.total, 2); + assert_eq!(page.items[0].files.len(), 1); +} + #[test] fn test_list_tags_returns_deduped_vocabulary_sorted_by_name() { let (_temp, mut lib) = setup_with_library(); diff --git a/crates/libcalibre/tests/snapshots/add_book_test__add_single_book_full_database.snap b/crates/libcalibre/tests/snapshots/add_book_test__add_single_book_full_database.snap index b01adccf..a73964c9 100644 --- a/crates/libcalibre/tests/snapshots/add_book_test__add_single_book_full_database.snap +++ b/crates/libcalibre/tests/snapshots/add_book_test__add_single_book_full_database.snap @@ -357,20 +357,6 @@ schema: type_name: BLOB nullable: false primary_key: false - - name: custom_column_1 - columns: - - name: id - type_name: INTEGER - nullable: true - primary_key: true - - name: book - type_name: INTEGER - nullable: true - primary_key: false - - name: value - type_name: BOOL - nullable: false - primary_key: false - name: custom_columns columns: - name: id @@ -677,17 +663,7 @@ data: books_tags_link: [] comments: [] conversion_options: [] - custom_column_1: [] - custom_columns: - - datatype: bool - display: "{}" - editable: 1 - id: 1 - is_multiple: 0 - label: read - mark_for_delete: 0 - name: Read - normalized: 0 + custom_columns: [] data: [] feeds: [] identifiers: [] diff --git a/crates/libcalibre/tests/test_triggers.rs b/crates/libcalibre/tests/test_triggers.rs index 98cf84f7..e6d4d9d8 100644 --- a/crates/libcalibre/tests/test_triggers.rs +++ b/crates/libcalibre/tests/test_triggers.rs @@ -33,8 +33,12 @@ fn test_books_insert_trigger_generates_sort_and_uuid() { assert_eq!(book.sortable_title, Some("Great Gatsby, The".to_string())); // Verify UUID was generated (should be a valid UUID format) - assert_eq!(book.uuid.len(), 36); // UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx - assert!(book.uuid.contains('-')); + let uuid = book + .uuid + .as_deref() + .expect("insert trigger should set UUID"); + assert_eq!(uuid.len(), 36); // UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + assert!(uuid.contains('-')); } /// Test that the books_update_trg trigger updates sort when title changes diff --git a/docs/headless-server.md b/docs/headless-server.md new file mode 100644 index 00000000..b483541c --- /dev/null +++ b/docs/headless-server.md @@ -0,0 +1,41 @@ +# Headless OPDS server + +`citadel-server` serves one Calibre library over the same OPDS runtime used by +the desktop application. It has no desktop, WebView, or remote management UI. + +Copy `crates/citadel-server/example-config.toml`, use absolute paths for the +library and state directory, then run: + +```sh +CITADEL_OPDS_PASSWORD='choose-a-password' cargo run -p citadel-server -- \ + --config /absolute/path/to/citadel-server.toml +``` + +The password is read from the environment variable named by +`credentials.passwordEnvironment`; it is never stored in the TOML file or +printed. Citadel persists only the username and Argon2id verifier beneath the +configured state directory. After the first successful start, the +`credentials` section and password environment variable may be omitted while +`authenticationEnabled` remains true. + +The process logs lifecycle state and every concrete OPDS URL. It remains in the +foreground until SIGINT or SIGTERM, then gracefully closes its listeners. +Interface selections use the same stable system interface names and IPv4/IPv6 +policy as desktop sharing. + +For a reverse proxy on the same host, the target can instead use explicit +concrete addresses without depending on interface discovery: + +```toml +[sharing.target] +type = "addresses" +addresses = ["127.0.0.1", "::1"] +``` + +Wildcard addresses such as `0.0.0.0` and `::` are rejected. Configure every +concrete address on which the process should listen. + +Citadel serves plain HTTP. For access beyond a trusted local network, place it +behind an externally managed TLS reverse proxy and firewall. Service +supervision, certificates, public-exposure controls, and container or operating +system packages are not provided by this initial server. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 51257b59..25548afd 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,13 +1,13 @@ [package] name = "citadel-rs" version = "0.6.1" -description = "Backend for Citadel, embedded into the Tauri app or run stand-alone." +description = "Backend for the Citadel desktop app." authors = ["Phil Denhoff"] license = "MIT" repository = "https://github.com/every-day-things/citadel" default-run = "citadel-rs" edition = "2021" -rust-version = "1.77" +rust-version.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -20,6 +20,7 @@ diesel = { version = "2.1.0", features = ["sqlite", "chrono", "returning_clauses epub = "2.1.1" mobi = "0.8.0" citadel-core = { path = "../crates/citadel-core" } +citadel-opds = { path = "../crates/citadel-opds" } libcalibre = { path = "../crates/libcalibre" } log = "0.4" quick-xml = "0.38" @@ -34,7 +35,6 @@ tauri-plugin-drag = "2.1.0" tauri-plugin-persisted-scope = { features = ["protocol-asset"] , version = "2" } tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] } urlencoding = "2.1.3" -uuid = { version = "1.6.1", features = [ "v4", "fast-rng", ] } zip = "0.6" tauri-plugin-dialog = "2" tauri-plugin-fs = "2" @@ -45,8 +45,10 @@ tauri-plugin-opener = "2" tauri-plugin-updater = "2" tauri-plugin-webdriver-automation = "0.1.3" image = { version = "0.25.6", default-features = false, features = ["jpeg", "png"] } +tokio = { version = "1.52.3", features = ["macros", "net", "rt-multi-thread", "sync", "time"] } [dev-dependencies] +axum = "0.8.9" tempfile = "3.8" [features] diff --git a/src-tauri/src/app_updates.rs b/src-tauri/src/app_updates.rs index 745a83dc..997cea44 100644 --- a/src-tauri/src/app_updates.rs +++ b/src-tauri/src/app_updates.rs @@ -1,5 +1,7 @@ use tauri_plugin_updater::UpdaterExt; +use citadel_opds::OpdsService; + #[derive(serde::Serialize, specta::Type)] pub struct UpdateCheckResult { pub has_update: bool, @@ -28,7 +30,10 @@ pub async fn clb_cmd_check_for_updates(app: tauri::AppHandle) -> Result Result { +pub async fn clb_cmd_install_update_if_available( + app: tauri::AppHandle, + opds: tauri::State<'_, OpdsService>, +) -> Result { let updater = app .updater() .map_err(|err| format!("Updater initialization failed: {err}"))?; @@ -42,6 +47,7 @@ pub async fn clb_cmd_install_update_if_available(app: tauri::AppHandle) -> Resul .await .map_err(|err| format!("Failed to download/install update: {err}"))?; + opds.stop().await; app.restart(); } Ok(None) => Ok("no-update".to_string()), diff --git a/src-tauri/src/libs/calibre/query.rs b/src-tauri/src/libs/calibre/query.rs index 366846a4..cfb2f190 100644 --- a/src-tauri/src/libs/calibre/query.rs +++ b/src-tauri/src/libs/calibre/query.rs @@ -116,7 +116,9 @@ pub fn clb_query_books( text: query.text, author_id, series_id: query.series_id, + tag_id: None, hide_read: query.hide_read, + require_file: false, sort: query.sort.into(), limit: query.limit.map(i64::from), offset: i64::from(query.offset), diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index d741b232..597085dd 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,6 +1,11 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +use std::sync::{ + atomic::{AtomicU8, Ordering}, + Arc, +}; + use libs::calibre; #[cfg(debug_assertions)] use specta_typescript::Typescript; @@ -16,6 +21,7 @@ pub mod libs { mod book; mod menu; mod metadata; +pub mod opds; mod state; fn run_tauri_backend() -> std::io::Result<()> { @@ -64,6 +70,15 @@ fn run_tauri_backend() -> std::io::Result<()> { metadata::commands::clb_query_metadata_by_isbn, app_updates::clb_cmd_check_for_updates, app_updates::clb_cmd_install_update_if_available, + // OPDS sharing commands + opds::commands::clb_query_opds_interfaces, + opds::commands::clb_cmd_start_opds, + opds::commands::clb_cmd_stop_opds, + opds::commands::clb_query_opds_status, + opds::commands::clb_query_opds_credential_status, + opds::commands::clb_cmd_configure_opds_credentials, + opds::commands::clb_cmd_generate_opds_credentials, + opds::commands::clb_cmd_clear_opds_credentials, // Window commands menu::clb_cmd_open_settings, ]); @@ -86,14 +101,21 @@ fn run_tauri_backend() -> std::io::Result<()> { tauri_builder = tauri_builder.plugin(tauri_plugin_webdriver_automation::init()); } - tauri_builder + let state = state::CitadelState::new(); + let opds_state = state.clone(); + let app = tauri_builder .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_updater::Builder::new().build()) - .manage(state::CitadelState::new()) + .manage(state) .invoke_handler(builder.invoke_handler()) .plugin(tauri_plugin_store::Builder::new().build()) .setup(move |app| { builder.mount_events(app); + let credential_path = app.path().app_data_dir()?.join("opds-credentials.json"); + app.manage(citadel_opds::OpdsService::new( + Arc::new(opds_state.clone()), + credential_path, + )?); // Native macOS menu bar: app menu with Settings…, File > Add // Book…, and the standard Edit/View/Window items. @@ -140,8 +162,39 @@ fn run_tauri_backend() -> std::io::Result<()> { .plugin(tauri_plugin_drag::init()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_clipboard_manager::init()) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); + .build(tauri::generate_context!()) + .expect("error while building tauri application"); + + const RUNNING: u8 = 0; + const STOPPING: u8 = 1; + const EXITING: u8 = 2; + let exit_phase = Arc::new(AtomicU8::new(RUNNING)); + app.run(move |app_handle, event| { + if let tauri::RunEvent::ExitRequested { code, api, .. } = event { + match exit_phase.compare_exchange( + RUNNING, + STOPPING, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => { + api.prevent_exit(); + let app_handle = app_handle.clone(); + let opds_service = app_handle.state::().inner().clone(); + let exit_phase = exit_phase.clone(); + let exit_code = code.unwrap_or(0); + tauri::async_runtime::spawn(async move { + opds_service.stop().await; + exit_phase.store(EXITING, Ordering::Release); + app_handle.exit(exit_code); + }); + } + Err(STOPPING) => api.prevent_exit(), + Err(EXITING) => {} + Err(_) => unreachable!(), + } + } + }); Ok(()) } diff --git a/src-tauri/src/opds/commands.rs b/src-tauri/src/opds/commands.rs new file mode 100644 index 00000000..ee44267d --- /dev/null +++ b/src-tauri/src/opds/commands.rs @@ -0,0 +1,72 @@ +use super::{ + GeneratedOpdsCredentials, OpdsCredentialStatus, OpdsNetworkInterface, OpdsService, + OpdsServiceStatus, OpdsStartConfig, OpdsStatusError, +}; + +#[tauri::command] +#[specta::specta] +pub fn clb_query_opds_credential_status( + service: tauri::State<'_, OpdsService>, +) -> Result { + Ok(service.credential_status()) +} + +#[tauri::command] +#[specta::specta] +pub async fn clb_cmd_configure_opds_credentials( + service: tauri::State<'_, OpdsService>, + username: String, + password: String, +) -> Result { + service.configure_credentials(username, password).await +} + +#[tauri::command] +#[specta::specta] +pub async fn clb_cmd_generate_opds_credentials( + service: tauri::State<'_, OpdsService>, + username: String, +) -> Result { + service.generate_credentials(username).await +} + +#[tauri::command] +#[specta::specta] +pub async fn clb_cmd_clear_opds_credentials( + service: tauri::State<'_, OpdsService>, +) -> Result { + service.clear_credentials().await +} + +#[tauri::command] +#[specta::specta] +pub async fn clb_query_opds_interfaces( + service: tauri::State<'_, OpdsService>, +) -> Result, OpdsStatusError> { + service.list_interfaces().await +} + +#[tauri::command] +#[specta::specta] +pub async fn clb_cmd_start_opds( + service: tauri::State<'_, OpdsService>, + config: OpdsStartConfig, +) -> Result { + service.start(config).await +} + +#[tauri::command] +#[specta::specta] +pub async fn clb_cmd_stop_opds( + service: tauri::State<'_, OpdsService>, +) -> Result { + Ok(service.stop().await) +} + +#[tauri::command] +#[specta::specta] +pub async fn clb_query_opds_status( + service: tauri::State<'_, OpdsService>, +) -> Result { + Ok(service.status().await) +} diff --git a/src-tauri/src/opds/mod.rs b/src-tauri/src/opds/mod.rs new file mode 100644 index 00000000..5f4c5a7c --- /dev/null +++ b/src-tauri/src/opds/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod commands; +pub use citadel_opds::*; diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 4736b0c4..c5e76ca5 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1,34 +1,71 @@ -use std::sync::Mutex; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, +}; -use libcalibre::Library; +use chrono::NaiveDateTime; +use libcalibre::{BookId, BookPage, CalibreError, Library, ResolvedBookAsset}; +use citadel_opds::{CatalogBookQuery, CatalogFacet, CatalogSource}; + +#[derive(Clone)] pub struct CitadelState { + inner: Arc, +} + +struct CitadelStateInner { library: Mutex>, current_library_path: Mutex>, + library_switch: Mutex<()>, + library_transitioning: AtomicBool, } impl CitadelState { pub fn new() -> Self { Self { - library: Mutex::new(None), - current_library_path: Mutex::new(None), + inner: Arc::new(CitadelStateInner { + library: Mutex::new(None), + current_library_path: Mutex::new(None), + library_switch: Mutex::new(()), + library_transitioning: AtomicBool::new(false), + }), } } /// Initialize or switch to a library pub fn init_library(&self, library_path: String) -> Result<(), String> { - let db_path = libcalibre::util::get_db_path(&library_path) - .ok_or_else(|| format!("Invalid library path: {}", library_path))?; - - let lib = Library::new(db_path).map_err(|e| format!("Failed to open library: {}", e))?; - - *self.library.lock().expect("Library mutex poisoned") = Some(lib); - *self - .current_library_path + let _switch = self + .inner + .library_switch .lock() - .expect("Library path mutex poisoned") = Some(library_path); + .expect("Library switch mutex poisoned"); + { + let _library = self.inner.library.lock().expect("Library mutex poisoned"); + self.inner + .library_transitioning + .store(true, Ordering::Release); + } + let result = (|| { + let db_path = libcalibre::util::get_db_path(&library_path) + .ok_or_else(|| format!("Invalid library path: {}", library_path))?; + let library = Library::new(db_path) + .map_err(|error| format!("Failed to open library: {error}"))?; - Ok(()) + *self.inner.library.lock().expect("Library mutex poisoned") = Some(library); + *self + .inner + .current_library_path + .lock() + .expect("Library path mutex poisoned") = Some(library_path); + Ok(()) + })(); + { + let _library = self.inner.library.lock().expect("Library mutex poisoned"); + self.inner + .library_transitioning + .store(false, Ordering::Release); + } + result } /// Execute a function with mutable access to the library @@ -36,7 +73,7 @@ impl CitadelState { where F: FnOnce(&mut Library) -> R, { - let mut lib_guard = self.library.lock().expect("Library mutex poisoned"); + let mut lib_guard = self.inner.library.lock().expect("Library mutex poisoned"); match lib_guard.as_mut() { Some(lib) => Ok(f(lib)), None => Err("No library initialized. Please load a library first.".to_string()), @@ -45,7 +82,8 @@ impl CitadelState { /// Get the current library path pub fn get_library_path(&self) -> Option { - self.current_library_path + self.inner + .current_library_path .lock() .expect("Library path mutex poisoned") .clone() @@ -53,9 +91,291 @@ impl CitadelState { /// Check if a library is currently loaded pub fn is_initialized(&self) -> bool { - self.library - .lock() - .expect("Library mutex poisoned") - .is_some() + let library = self.inner.library.lock().expect("Library mutex poisoned"); + !self.is_library_transitioning() && library.is_some() + } + + pub fn is_library_transitioning(&self) -> bool { + self.inner.library_transitioning.load(Ordering::Acquire) + } + + pub fn active_library_id(&self) -> Result { + let mut library = self.inner.library.lock().expect("Library mutex poisoned"); + if self.is_library_transitioning() { + return Err(CalibreError::LibraryNotInitialized); + } + library + .as_mut() + .ok_or(CalibreError::LibraryNotInitialized)? + .library_uuid() + } + + /// Resolve under the library mutex and return only owned data. Opening and + /// streaming the file must happen after this method returns. + pub fn resolve_book_asset( + &self, + book_id: BookId, + format: &str, + ) -> Result { + let mut library = self.inner.library.lock().expect("Library mutex poisoned"); + if self.is_library_transitioning() { + return Err(CalibreError::LibraryNotInitialized); + } + library + .as_mut() + .ok_or(CalibreError::LibraryNotInitialized)? + .resolve_book_file(book_id, format) + } + + pub fn resolve_book_cover(&self, book_id: BookId) -> Result { + let mut library = self.inner.library.lock().expect("Library mutex poisoned"); + if self.is_library_transitioning() { + return Err(CalibreError::LibraryNotInitialized); + } + library + .as_mut() + .ok_or(CalibreError::LibraryNotInitialized)? + .resolve_book_cover(book_id) + } + + pub fn opds_book_page( + &self, + query: CatalogBookQuery, + ) -> Result<(String, Option, BookPage), CalibreError> { + let mut library = self.inner.library.lock().expect("Library mutex poisoned"); + if self.is_library_transitioning() { + return Err(CalibreError::LibraryNotInitialized); + } + let library = library + .as_mut() + .ok_or(CalibreError::LibraryNotInitialized)?; + let library_uuid = library.library_uuid()?; + let updated_at = library.catalog_updated_at()?; + let page = match query.into_calibre() { + Some(query) => library.query_acquirable_books_with(query)?, + None => BookPage { + items: Vec::new(), + total: 0, + }, + }; + Ok((library_uuid, updated_at, page)) + } + + fn with_opds_library( + &self, + operation: impl FnOnce(&mut Library) -> Result, + ) -> Result { + let mut library = self.inner.library.lock().expect("Library mutex poisoned"); + if self.is_library_transitioning() { + return Err(CalibreError::LibraryNotInitialized); + } + operation( + library + .as_mut() + .ok_or(CalibreError::LibraryNotInitialized)?, + ) + } +} + +impl CatalogSource for CitadelState { + fn active_library_id(&self) -> Result { + CitadelState::active_library_id(self) + } + + fn book_page( + &self, + query: CatalogBookQuery, + ) -> Result<(String, Option, BookPage), CalibreError> { + self.opds_book_page(query) + } + + fn authors(&self) -> Result, CalibreError> { + self.with_opds_library(|library| { + library.list_authors().map(|authors| { + authors + .into_iter() + .map(|author| CatalogFacet { + id: author.id.as_i32(), + title: author.name, + book_count: Some(author.book_count), + }) + .collect() + }) + }) + } + + fn series(&self) -> Result, CalibreError> { + self.with_opds_library(|library| { + library.list_series().map(|series| { + series + .into_iter() + .map(|series| CatalogFacet { + id: series.id, + title: series.name, + book_count: Some(series.book_count), + }) + .collect() + }) + }) + } + + fn tags(&self) -> Result, CalibreError> { + self.with_opds_library(|library| { + library.list_tags().map(|tags| { + tags.into_iter() + .map(|tag| CatalogFacet { + id: tag.id, + title: tag.name, + book_count: Some(tag.book_count), + }) + .collect() + }) + }) + } + + fn book_file(&self, book_id: BookId, format: &str) -> Result { + self.resolve_book_asset(book_id, format) + } + + fn book_cover(&self, book_id: BookId) -> Result { + self.resolve_book_cover(book_id) + } +} + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, path::Path, time::Duration}; + + use axum::http::StatusCode; + + use super::*; + + fn extract_empty_library() -> tempfile::TempDir { + let directory = tempfile::tempdir().unwrap(); + let zip_path = + Path::new(env!("CARGO_MANIFEST_DIR")).join("resources/empty_7_2_calibre_lib.zip"); + let file = std::fs::File::open(zip_path).unwrap(); + zip::ZipArchive::new(file) + .unwrap() + .extract(directory.path()) + .unwrap(); + directory + } + + fn add_asset(directory: &Path, byte: u8, length: usize) -> BookId { + let root = directory.to_string_lossy().into_owned(); + let database = libcalibre::util::get_db_path(&root).unwrap(); + let mut library = libcalibre::Library::new(database).unwrap(); + let source = directory.join(format!("source-{byte}.epub")); + std::fs::write(&source, vec![byte; length]).unwrap(); + let book = library + .add_book(libcalibre::BookAdd { + title: format!("Library {byte}"), + author_names: vec!["Citadel Test".to_string()], + tags: None, + series: None, + series_index: None, + publisher: None, + publication_date: None, + rating: None, + comments: None, + identifiers: HashMap::new(), + language: Some("eng".to_string()), + file_paths: vec![source], + }) + .unwrap(); + book.id + } + + #[tokio::test(flavor = "multi_thread")] + async fn running_router_switches_library_at_same_url_and_is_unavailable_during_transition() { + let first = extract_empty_library(); + let second = extract_empty_library(); + let first_book = add_asset(first.path(), b'A', 2 * 1024 * 1024); + let second_book = add_asset(second.path(), b'B', 2 * 1024 * 1024); + assert_eq!(first_book, second_book); + let second_path = second.path().to_string_lossy().into_owned(); + let second_db = libcalibre::util::get_db_path(&second_path).unwrap(); + libcalibre::Library::new(second_db) + .unwrap() + .randomize_library_uuid() + .unwrap(); + + let state = CitadelState::new(); + state + .init_library(first.path().to_string_lossy().into_owned()) + .unwrap(); + let first_id = state.active_library_id().unwrap(); + let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .await + .unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let server_state = state.clone(); + let server = tokio::spawn(async move { + axum::serve( + listener, + citadel_opds::router( + Arc::new(server_state), + citadel_opds::OpdsBasicAuth::disabled(), + ), + ) + .await + .unwrap(); + }); + let client = reqwest::Client::new(); + + let first_response = client.get(format!("{base}/opds")).send().await.unwrap(); + assert_eq!(first_response.status(), StatusCode::OK); + assert!(first_response.text().await.unwrap().contains(&first_id)); + + let asset_url = format!( + "{base}/opds/books/{}/files/EPUB/book.epub", + first_book.as_i32() + ); + let old_download = client.get(&asset_url).send().await.unwrap(); + assert_eq!(old_download.status(), StatusCode::OK); + let responsive_feed = tokio::time::timeout( + Duration::from_secs(1), + client.get(format!("{base}/opds")).send(), + ) + .await + .expect("a stalled large download must not block feed requests") + .unwrap(); + assert_eq!(responsive_feed.status(), StatusCode::OK); + + state + .inner + .library_transitioning + .store(true, Ordering::Release); + let transition_response = reqwest::get(format!("{base}/opds")).await.unwrap(); + assert_eq!( + transition_response.status(), + StatusCode::SERVICE_UNAVAILABLE + ); + state + .inner + .library_transitioning + .store(false, Ordering::Release); + + state.init_library(second_path).unwrap(); + let second_id = state.active_library_id().unwrap(); + assert_ne!(first_id, second_id); + let second_response = client.get(format!("{base}/opds")).send().await.unwrap(); + assert_eq!(second_response.status(), StatusCode::OK); + assert!(second_response.text().await.unwrap().contains(&second_id)); + let new_bytes = client + .get(&asset_url) + .send() + .await + .unwrap() + .bytes() + .await + .unwrap(); + assert_eq!(new_bytes.len(), 2 * 1024 * 1024); + assert!(new_bytes.iter().all(|byte| *byte == b'B')); + let old_bytes = old_download.bytes().await.unwrap(); + assert_eq!(old_bytes.len(), 2 * 1024 * 1024); + assert!(old_bytes.iter().all(|byte| *byte == b'A')); + server.abort(); } } diff --git a/src/bindings.ts b/src/bindings.ts index 7d82df85..9edde3da 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -289,6 +289,70 @@ async clbCmdInstallUpdateIfAvailable() : Promise> { else return { status: "error", error: e as any }; } }, +async clbQueryOpdsInterfaces() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("clb_query_opds_interfaces") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async clbCmdStartOpds(config: OpdsStartConfig) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("clb_cmd_start_opds", { config }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async clbCmdStopOpds() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("clb_cmd_stop_opds") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async clbQueryOpdsStatus() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("clb_query_opds_status") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async clbQueryOpdsCredentialStatus() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("clb_query_opds_credential_status") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async clbCmdConfigureOpdsCredentials(username: string, password: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("clb_cmd_configure_opds_credentials", { username, password }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async clbCmdGenerateOpdsCredentials(username: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("clb_cmd_generate_opds_credentials", { username }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async clbCmdClearOpdsCredentials() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("clb_cmd_clear_opds_credentials") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async clbCmdOpenSettings() : Promise { await TAURI_INVOKE("clb_cmd_open_settings"); } @@ -402,6 +466,7 @@ export type DetectionSource = * Calibre's default `~/Calibre Library` folder. */ "default-folder" +export type GeneratedOpdsCredentials = { username: string; password: string } /** * Book identifiers, such as ISBN, DOI, Google Books ID, etc. */ @@ -490,6 +555,16 @@ export type LocalOrRemoteUrl = { kind: LocalOrRemote; url: string; local_path: s */ export type MetadataProvider = "hardcover" | "loc" | "dnb" | "k10plus" | "openlibrary" export type NewAuthor = { name: string; sortable_name: string | null } +export type OpdsBindTarget = { type: "allLocalNetworks" } | { type: "interface"; id: string } | { type: "addresses"; addresses: string[] } +export type OpdsCredentialStatus = { configured: boolean; username: string | null } +export type OpdsErrorCode = "invalidPort" | "libraryNotReady" | "configurationConflict" | "interfaceUnavailable" | "interfaceEnumerationFailed" | "portUnavailable" | "listenerFailed" | "invalidCredentials" | "credentialsRequired" | "credentialStorageFailed" | "unexpected" +export type OpdsInterfaceKind = "lan" | "vpn" | "loopback" | "other" +export type OpdsInterfaceState = "up" | "down" +export type OpdsLifecycleState = "stopped" | "starting" | "running" | "waitingForInterface" | "error" | "stopping" +export type OpdsNetworkInterface = { id: string; label: string; kind: OpdsInterfaceKind; state: OpdsInterfaceState; addresses: string[]; shareable: boolean } +export type OpdsServiceStatus = { state: OpdsLifecycleState; target: OpdsBindTarget | null; port: number | null; activeLibraryId: string | null; urls: string[]; error: OpdsStatusError | null } +export type OpdsStartConfig = { target: OpdsBindTarget; port: number; authenticationEnabled: boolean } +export type OpdsStatusError = { code: OpdsErrorCode; message: string } export type ProviderStatus = { provider: MetadataProvider; is_valid: boolean; message: string } export type RemoteFile = { url: string } /** diff --git a/src/components/icons/F7AntennaRadiowavesLeftRight.tsx b/src/components/icons/F7AntennaRadiowavesLeftRight.tsx new file mode 100644 index 00000000..60dcf4b6 --- /dev/null +++ b/src/components/icons/F7AntennaRadiowavesLeftRight.tsx @@ -0,0 +1,13 @@ +import type { SVGProps } from "react"; + +export function F7AntennaRadiowavesLeftRight(props: SVGProps) { + return ( + + Sharing + + + ); +} diff --git a/src/components/organisms/SettingsPanes.tsx b/src/components/organisms/SettingsPanes.tsx index 46ba67ed..ea07ec70 100644 --- a/src/components/organisms/SettingsPanes.tsx +++ b/src/components/organisms/SettingsPanes.tsx @@ -2,11 +2,13 @@ import type { KeyboardEvent, ReactNode } from "react"; import { useCallback, useId, useRef, useState } from "react"; import { commands } from "@/bindings"; import { F7BookFill } from "@/components/icons/F7BookFill"; +import { F7AntennaRadiowavesLeftRight } from "@/components/icons/F7AntennaRadiowavesLeftRight"; import { F7Gear } from "@/components/icons/F7Gear"; import { FluentLibraryFilled } from "@/components/icons/FluentLibraryFilled"; import type { AddLibraryResult } from "@/components/molecules/SwitchLibraryForm"; import { SwitchLibraryForm } from "@/components/molecules/SwitchLibraryForm"; import classes from "@/components/organisms/SettingsPanes.module.css"; +import { SharingSettings } from "@/components/organisms/SharingSettings"; import { Button, SegmentedControl, Switch, TextInput } from "@/components/ui"; import { ADOPT_INVALID_ERROR } from "@/lib/first-run/machine"; import { useAppUpdates } from "@/lib/hooks/use-app-updates"; @@ -19,7 +21,12 @@ import { createLibrary, setActiveLibrary } from "@/stores/settings/actions"; import { useAnySourceEnabled } from "@/stores/settings/metadata-providers"; import { useSettings } from "@/stores/settings/store"; -const SETTINGS_TABS = ["general", "library", "integrations"] as const; +const SETTINGS_TABS = [ + "general", + "library", + "sharing", + "integrations", +] as const; type SettingsTab = (typeof SETTINGS_TABS)[number]; const TAB_META: Record< @@ -34,6 +41,10 @@ const TAB_META: Record< label: "Library", icon: (className) => , }, + sharing: { + label: "Sharing", + icon: (className) => , + }, integrations: { label: "Metadata", icon: (className) => , @@ -145,6 +156,7 @@ export const SettingsPanes = ({ onRequestClose }: SettingsPanesProps) => {
{activeTab === "general" && } {activeTab === "library" && } + {activeTab === "sharing" && } {activeTab === "integrations" && }
diff --git a/src/components/organisms/SharingSettings.module.css b/src/components/organisms/SharingSettings.module.css new file mode 100644 index 00000000..213dddb6 --- /dev/null +++ b/src/components/organisms/SharingSettings.module.css @@ -0,0 +1,311 @@ +.stack { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.sectionTitle { + margin: 0; + font-size: 1rem; + font-weight: 600; + line-height: 1.3; + color: var(--ctd-ink); +} + +.note, +.unsupported p { + max-width: 65ch; + margin: 0.25rem 0 0; + font-size: 0.75rem; + line-height: 1.45; + color: var(--ctd-ink-soft); +} + +.unsupported { + padding: 0.25rem 0.125rem; +} + +.unsupported h3 { + margin: 0; + font-size: 1rem; + font-weight: 600; +} + +.group, +.details { + border: 1px solid var(--ctd-border); + border-radius: 0.5rem; + background: var(--ctd-surface); +} + +:global([data-theme="dark"]) .group, +:global([data-theme="dark"]) .details { + background: var(--ctd-surface-strong); +} + +.fieldRow, +.switchRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + min-height: 2.625rem; + padding: 0.5rem 0.875rem; +} + +.group > * + * { + border-top: 1px solid var(--ctd-border); +} + +.fieldRow > label, +.switchRow label { + font-size: 0.8125rem; + color: var(--ctd-ink); +} + +.switchRow p { + margin: 0.125rem 0 0; + font-size: 0.75rem; + color: var(--ctd-ink-soft); +} + +.portInput.portInput { + width: 6rem; + text-align: right; +} + +.credentials { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; + padding: 0.75rem 0.875rem 0.875rem; +} + +.transientNote { + grid-column: 1 / -1; + margin: -0.125rem 0 0; + font-size: 0.6875rem; + line-height: 1.4; + color: var(--ctd-ink-soft); +} + +.passwordActions { + display: flex; + align-items: flex-end; +} + +.generatedPassword { + grid-column: 1 / -1; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.625rem 0.75rem; + border: 1px solid var(--ctd-border); + border-radius: 0.375rem; + background: var(--ctd-bg); +} + +.generatedPassword > div { + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 0; +} + +.generatedPassword strong { + font-size: 0.6875rem; + font-weight: 600; + color: var(--ctd-ink-soft); +} + +.generatedPassword code { + overflow: hidden; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.75rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.generatedPassword button svg { + width: 0.875rem; + height: 0.875rem; +} + +.warning, +.error { + margin: 0; + font-size: 0.75rem; + line-height: 1.45; +} + +.warning { + color: var(--ctd-ink-soft); +} + +.error { + color: var(--ctd-danger-text); +} + +.warningBlock, +.statusMessage { + padding: 0.75rem 0.875rem; + border: 1px solid var(--ctd-border); + border-radius: 0.5rem; + background: color-mix(in oklab, var(--ctd-surface-strong) 72%, transparent); +} + +.warningBlock strong, +.statusMessage strong { + font-size: 0.75rem; + font-weight: 600; + color: var(--ctd-ink); +} + +.warningBlock p, +.statusMessage p { + margin: 0.1875rem 0 0; + font-size: 0.75rem; + line-height: 1.45; + color: var(--ctd-ink-soft); +} + +.actions { + display: flex; + justify-content: flex-end; +} + +.statusHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.status { + display: inline-flex; + align-items: center; + gap: 0.375rem; + margin-bottom: 0.25rem; + font-size: 0.6875rem; + font-weight: 600; + color: var(--ctd-ink-soft); +} + +.status > span { + width: 0.4375rem; + height: 0.4375rem; + border-radius: 50%; + background: currentColor; +} + +.status[data-state="running"] { + color: var(--ctd-accent); +} + +.status[data-state="error"] { + color: var(--ctd-danger-text); +} + +.details { + display: grid; + grid-template-columns: 1fr 1fr; +} + +.details > div { + min-width: 0; + padding: 0.625rem 0.875rem; +} + +.details > div:nth-child(even) { + border-left: 1px solid var(--ctd-border); +} + +.details > div:nth-child(n + 3) { + border-top: 1px solid var(--ctd-border); +} + +.details dt, +.details dd { + margin: 0; +} + +.details dt, +.listTitle { + font-size: 0.6875rem; + font-weight: 600; + color: var(--ctd-ink-soft); +} + +.details dd { + margin-top: 0.125rem; + overflow: hidden; + font-size: 0.8125rem; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--ctd-ink); +} + +.details dd.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.6875rem; +} + +.listTitle { + margin: 0 0 0.375rem 0.125rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.urlList { + margin: 0; + padding: 0; + border: 1px solid var(--ctd-border); + border-radius: 0.5rem; + list-style: none; + overflow: hidden; +} + +.urlList li { + display: flex; + align-items: center; + gap: 0.5rem; + min-height: 2.5rem; + padding: 0.375rem 0.5rem 0.375rem 0.75rem; + background: var(--ctd-surface); +} + +.urlList li + li { + border-top: 1px solid var(--ctd-border); +} + +.urlList code { + min-width: 0; + flex: 1; + overflow: hidden; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.6875rem; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--ctd-ink); +} + +.urlList button svg { + width: 0.875rem; + height: 0.875rem; +} + +@media (max-width: 34rem) { + .credentials, + .details { + grid-template-columns: 1fr; + } + + .details > div:nth-child(even) { + border-left: 0; + } + + .details > div + div { + border-top: 1px solid var(--ctd-border); + } +} diff --git a/src/components/organisms/SharingSettings.tsx b/src/components/organisms/SharingSettings.tsx new file mode 100644 index 00000000..898fd93d --- /dev/null +++ b/src/components/organisms/SharingSettings.tsx @@ -0,0 +1,390 @@ +import type { OpdsLifecycleState, OpdsServiceStatus } from "@/bindings"; +import { useCallback, useEffect, useId, useMemo, useState } from "react"; +import { TablerCopy } from "@/components/icons/TablerCopy"; +import classes from "@/components/organisms/SharingSettings.module.css"; +import { Button, Select, Switch, TextInput } from "@/components/ui"; +import { useOpdsSharing } from "@/lib/hooks/use-opds-sharing"; +import { + hasValidationErrors, + isOperationalStatus, + passwordFreeSettings, + selectedInterfaceUnavailable, + sharingTargetOptions, + targetFromValue, + targetValue, + validateSharing, +} from "@/lib/opds-sharing"; +import type { SharingSettings as SharingPreferences } from "@/lib/platform/settings/types"; +import { usePlatform } from "@/lib/platform/context"; +import { useSettings } from "@/stores/settings/store"; + +const STATUS_LABELS: Record = { + stopped: "Not sharing", + starting: "Starting", + running: "Sharing", + waitingForInterface: "Waiting for network", + error: "Needs attention", + stopping: "Stopping", +}; + +const targetLabel = (status: OpdsServiceStatus): string => { + if (!status.target) return "Not selected"; + if (status.target.type === "allLocalNetworks") return "All local networks"; + if (status.target.type === "interface") return status.target.id; + return status.target.addresses.join(", "); +}; + +export const SharingSettings = () => { + const platform = usePlatform(); + const supported = platform.capabilities.supportsLocalOpdsServer; + const sharing = useSettings((state) => state.sharing); + const activeLibraryId = useSettings((state) => state.activeLibraryId); + const libraries = useSettings((state) => state.libraryPaths); + const setSharing = useSettings((state) => state.setSharing); + const controller = useOpdsSharing(supported); + const [portInput, setPortInput] = useState(String(sharing.port)); + const [password, setPassword] = useState(""); + const [generatedPassword, setGeneratedPassword] = useState( + null, + ); + const [copiedUrl, setCopiedUrl] = useState(null); + const authSwitchId = useId(); + + useEffect(() => setPortInput(String(sharing.port)), [sharing.port]); + + const currentLibrary = libraries.find( + (library) => library.id === activeLibraryId, + ); + const options = useMemo( + () => sharingTargetOptions(controller.interfaces, sharing.target), + [controller.interfaces, sharing.target], + ); + const unavailable = selectedInterfaceUnavailable( + controller.interfaces, + sharing.target, + ); + const draft = useMemo( + () => ({ ...sharing, port: Number(portInput) }), + [sharing, portInput], + ); + const validation = validateSharing({ + settings: draft, + password, + credentialsConfigured: controller.credentials.configured, + configuredUsername: controller.credentials.username, + }); + const operational = isOperationalStatus(controller.status); + const passwordConfigured = + controller.credentials.configured && + controller.credentials.username === sharing.username.trim(); + + const persist = useCallback( + (patch: Partial) => { + const current = useSettings.getState().sharing; + return setSharing(passwordFreeSettings({ ...current, ...patch })); + }, + [setSharing], + ); + + const handleStart = useCallback(async () => { + if (hasValidationErrors(validation)) return; + await persist({ port: draft.port }); + if (await controller.start(passwordFreeSettings(draft), password)) { + setPassword(""); + setGeneratedPassword(null); + } + }, [controller, draft, password, persist, validation]); + + const generatePassword = useCallback(async () => { + if (!sharing.username.trim()) return; + const generated = await controller.generatePassword(sharing.username); + if (!generated) return; + setPassword(""); + setGeneratedPassword(generated); + }, [controller, sharing.username]); + + const copy = useCallback( + async (url: string) => { + await platform.clipboard.writeText(url); + setCopiedUrl(url); + window.setTimeout(() => setCopiedUrl(null), 1_500); + }, + [platform], + ); + + if (!supported) { + return ( +
+

Sharing requires the desktop app

+

+ The web version cannot open a local network server. Open Citadel on + macOS or another supported desktop to share this library. +

+
+ ); + } + + if (controller.isLoading && controller.status === null) { + return

Checking sharing status…

; + } + + if (operational && controller.status) { + return ( + void controller.stop()} + /> + ); + } + + return ( +
+
+

Local network sharing

+

+ Start an OPDS catalog for the active library. Sharing stops when + Citadel quits and must be started again after each launch. +

+
+ +
+
+ +