From 7a0b67da64666f6ae1b41791d7784f4bccc2d1a2 Mon Sep 17 00:00:00 2001 From: Daniel Norman Date: Tue, 6 Oct 2026 18:21:31 +0200 Subject: [PATCH] Add release COB ports and commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add release types and the Releases and ReleasesMut ports - Create, delete and redact releases - Register, attest and locate artifacts - Filter releases with radicle-artifact - Add release commands and mirror them in test API Co-authored-by: Brandon Oxendine Co-authored-by: Rūdolfs Ošiņš --- Cargo.lock | 692 ++++++++++++++++-- crates/radicle-tauri/src/commands/cob.rs | 1 + .../radicle-tauri/src/commands/cob/release.rs | 196 +++++ crates/radicle-tauri/src/lib.rs | 15 + crates/radicle-types/Cargo.toml | 2 + .../bindings/cob/release/Artifact.ts | 28 + .../bindings/cob/release/ArtifactDigest.ts | 20 + .../bindings/cob/release/Location.ts | 7 + .../bindings/cob/release/Redaction.ts | 7 + .../bindings/cob/release/Release.ts | 19 + .../bindings/cob/release/ReleaseCounts.ts | 13 + .../bindings/cob/release/ReleaseFilter.ts | 16 + .../bindings/cob/release/ReleaseScope.ts | 7 + crates/radicle-types/bindings/repo/Tag.ts | 6 + crates/radicle-types/src/cobs.rs | 1 + crates/radicle-types/src/cobs/release.rs | 224 ++++++ crates/radicle-types/src/error.rs | 37 + crates/radicle-types/src/lib.rs | 4 + crates/radicle-types/src/repo.rs | 6 + crates/radicle-types/src/traits.rs | 2 + crates/radicle-types/src/traits/release.rs | 326 +++++++++ .../radicle-types/src/traits/release_mut.rs | 641 ++++++++++++++++ crates/radicle-types/src/traits/repo.rs | 2 + crates/test-http-api/src/api.rs | 290 ++++++++ crates/test-http-api/tests/parity.rs | 11 +- docs/adr/0002-backend-architecture.md | 4 +- 26 files changed, 2520 insertions(+), 57 deletions(-) create mode 100644 crates/radicle-tauri/src/commands/cob/release.rs create mode 100644 crates/radicle-types/bindings/cob/release/Artifact.ts create mode 100644 crates/radicle-types/bindings/cob/release/ArtifactDigest.ts create mode 100644 crates/radicle-types/bindings/cob/release/Location.ts create mode 100644 crates/radicle-types/bindings/cob/release/Redaction.ts create mode 100644 crates/radicle-types/bindings/cob/release/Release.ts create mode 100644 crates/radicle-types/bindings/cob/release/ReleaseCounts.ts create mode 100644 crates/radicle-types/bindings/cob/release/ReleaseFilter.ts create mode 100644 crates/radicle-types/bindings/cob/release/ReleaseScope.ts create mode 100644 crates/radicle-types/src/cobs/release.rs create mode 100644 crates/radicle-types/src/traits/release.rs create mode 100644 crates/radicle-types/src/traits/release_mut.rs diff --git a/Cargo.lock b/Cargo.lock index 838c4c736..ca2bb84de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,7 +14,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -26,7 +26,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -243,6 +243,12 @@ dependencies = [ "x11rb", ] +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "ascii" version = "1.1.0" @@ -414,6 +420,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -488,6 +503,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base256emoji" version = "1.0.2" @@ -536,7 +557,7 @@ checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" dependencies = [ "blowfish", "pbkdf2", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -578,6 +599,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -587,6 +621,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block-padding" version = "0.3.3" @@ -862,7 +905,18 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", ] [[package]] @@ -877,13 +931,26 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cid" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a304f95f84d169a6f31c4d0a30d784643aaa0bbc9c1e449a2c23e963ec4971" +dependencies = [ + "multibase", + "multihash", + "serde", + "serde_bytes", + "unsigned-varint", +] + [[package]] name = "cipher" version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] @@ -937,6 +1004,15 @@ dependencies = [ "error-code", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.19", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -962,6 +1038,18 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console" +version = "0.16.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -994,6 +1082,21 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.18.1" @@ -1053,6 +1156,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -1062,6 +1174,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.16" @@ -1077,6 +1195,33 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -1090,7 +1235,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -1102,10 +1247,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", + "rand_core 0.10.1", +] + [[package]] name = "cssparser" version = "0.36.0" @@ -1161,15 +1316,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest", - "fiat-crypto", + "digest 0.10.7", + "fiat-crypto 0.2.9", "rustc_version", "subtle", "zeroize", ] +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto 0.3.0", + "rand_core 0.10.1", + "rustc_version", + "serde", + "subtle", + "zeroize", +] + [[package]] name = "curve25519-dalek-derive" version = "0.1.1" @@ -1399,10 +1572,12 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ + "convert_case", "proc-macro2", "quote", "rustc_version", "syn 2.0.119", + "unicode-xid", ] [[package]] @@ -1411,12 +1586,22 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", + "block-buffer 0.10.4", "const-oid", - "crypto-common", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", +] + [[package]] name = "dirs" version = "6.0.0" @@ -1493,6 +1678,15 @@ dependencies = [ "const-random", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dom_query" version = "0.27.0" @@ -1641,7 +1835,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", - "digest", + "digest 0.10.7", "elliptic-curve", "rfc6979", "signature 2.2.0", @@ -1668,16 +1862,42 @@ dependencies = [ "signature 2.2.0", ] +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "serdect", + "signature 3.0.0", +] + [[package]] name = "ed25519-dalek" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "curve25519-dalek", + "curve25519-dalek 4.1.3", "ed25519 2.2.3", "serde", - "sha2", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +dependencies = [ + "curve25519-dalek 5.0.0", + "ed25519 3.0.0", + "rand_core 0.10.1", + "serde", + "sha2 0.11.0", + "signature 3.0.0", "subtle", "zeroize", ] @@ -1694,14 +1914,14 @@ version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "base16ct", + "base16ct 0.2.0", "crypto-bigint", - "digest", + "digest 0.10.7", "ff", "generic-array", "group", "pkcs8", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", @@ -1727,6 +1947,24 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1868,7 +2106,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1878,6 +2116,12 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "field-offset" version = "0.3.6" @@ -2058,6 +2302,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + [[package]] name = "gdk" version = "0.18.2" @@ -2212,8 +2465,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -2341,7 +2597,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -2408,6 +2664,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2441,6 +2706,20 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.4.1" @@ -2471,7 +2750,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -2543,6 +2822,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.10.1" @@ -2767,6 +3055,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.18.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" +dependencies = [ + "console", + "portable-atomic", + "unicode-width", + "unit-prefix", + "web-time", +] + [[package]] name = "infer" version = "0.19.0" @@ -2786,12 +3087,45 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inquire" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756" +dependencies = [ + "bitflags 2.13.1", + "crossterm", + "dyn-clone", + "fuzzy-matcher", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "ipnet" version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "iroh-base" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ae9092be76c9f5429776ab3394f5a5a78dc19dc2524267e884a5b9781546c3f" +dependencies = [ + "curve25519-dalek 5.0.0", + "data-encoding", + "data-encoding-macro", + "derive_more", + "ed25519-dalek 3.0.0", + "getrandom 0.4.3", + "n0-error", + "rand 0.10.3", + "serde", + "url", + "zeroize", +] + [[package]] name = "is-docker" version = "0.2.0" @@ -2951,7 +3285,7 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -3080,6 +3414,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -3171,6 +3511,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -3219,6 +3560,37 @@ dependencies = [ "data-encoding-macro", ] +[[package]] +name = "multihash" +version = "0.19.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c63b00ad74d57e8c9aa870b5fccebf2fd64a308a5aee9f1bb88e4aea19447" +dependencies = [ + "serde", + "unsigned-varint", +] + +[[package]] +name = "n0-error" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26c59ea174675ce6bc196878851e5049e11b8b1f9af3ba87e4d6df8d828bb001" +dependencies = [ + "n0-error-macros", + "spez", +] + +[[package]] +name = "n0-error-macros" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4412346410d6616f3668c40e53c298e5320c7b856f7a960e5914e442601be79f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "ndk" version = "0.9.0" @@ -3295,7 +3667,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.7", "smallvec", "zeroize", ] @@ -3646,7 +4018,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -3658,7 +4030,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -3667,12 +4039,12 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" dependencies = [ - "base16ct", + "base16ct 0.2.0", "ecdsa", "elliptic-curve", "primeorder", - "rand_core", - "sha2", + "rand_core 0.6.4", + "sha2 0.10.9", ] [[package]] @@ -3735,7 +4107,7 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -3802,7 +4174,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared 0.11.3", - "rand", + "rand 0.8.7", ] [[package]] @@ -3962,7 +4334,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -3974,11 +4346,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -4102,7 +4493,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b439bd4242da51d62d18c95e6a6add749346756b0d1a587dfd0cc22fa6b5f3f0" dependencies = [ - "rand", + "rand 0.8.7", ] [[package]] @@ -4178,6 +4569,64 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "radicle-artifact" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d174f69a862d64be21d7e0618e68a4bb7d224cfbff0f16e979470e62de9714d2" +dependencies = [ + "blake3", + "cid", + "clap", + "indexmap 2.14.0", + "indicatif", + "inquire", + "log", + "radicle", + "radicle-artifact-client", + "radicle-artifact-core", + "serde", + "serde_json", + "sqlite", + "thiserror 2.0.19", + "url", +] + +[[package]] +name = "radicle-artifact-client" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f6264d95863114ff711b9d2f5e07c64a9e5c8b80f974aa8d793e19f722c000c" +dependencies = [ + "cid", + "radicle", + "radicle-artifact-core", + "serde", + "serde_json", + "thiserror 2.0.19", +] + +[[package]] +name = "radicle-artifact-core" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883197a26fa5ad9cabc0fc6095015db4598ec438a67f813eef45f54f943f7b50" +dependencies = [ + "blake3", + "cid", + "dunce", + "iroh-base", + "multibase", + "multihash", + "postcard", + "radicle", + "serde", + "serde_json", + "thiserror 2.0.19", + "url", + "walkdir", +] + [[package]] name = "radicle-cob" version = "0.21.1" @@ -4221,9 +4670,9 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76de6172f3ccae0f489578f4a5da45bc77ff8855ace6352b002d94c0e0783bb5" dependencies = [ - "curve25519-dalek", + "curve25519-dalek 4.1.3", "cyphernet", - "ed25519-dalek", + "ed25519-dalek 2.2.0", "multibase", "qcheck", "radicle-git-ref-format", @@ -4366,6 +4815,8 @@ dependencies = [ "log", "mime-infer", "radicle", + "radicle-artifact", + "radicle-artifact-core", "radicle-job", "radicle-localtime", "radicle-surf", @@ -4390,7 +4841,18 @@ checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20 0.10.2", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -4400,7 +4862,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -4412,6 +4874,12 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -4562,14 +5030,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ "const-oid", - "digest", + "digest 0.10.7", "num-bigint-dig", "num-integer", "num-traits", "pkcs1", "pkcs8", - "rand_core", - "sha2", + "rand_core 0.6.4", + "sha2 0.10.9", "signature 2.2.0", "spki", "subtle", @@ -4717,7 +5185,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct", + "base16ct 0.2.0", "der", "generic-array", "pkcs8", @@ -4791,6 +5259,16 @@ dependencies = [ "typeid", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -4920,6 +5398,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct 1.0.0", + "serde", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -4958,8 +5446,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -4968,7 +5467,7 @@ version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" dependencies = [ - "digest", + "digest 0.10.7", "keccak", ] @@ -5010,6 +5509,17 @@ dependencies = [ "signal-hook-registry", ] +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -5032,8 +5542,17 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", - "rand_core", + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "rand_core 0.10.1", ] [[package]] @@ -5145,11 +5664,25 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spez" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "spin" version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] [[package]] name = "spki" @@ -5214,7 +5747,7 @@ dependencies = [ "aes", "aes-gcm", "cbc", - "chacha20", + "chacha20 0.9.1", "cipher", "ctr", "poly1305", @@ -5230,7 +5763,7 @@ checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" dependencies = [ "base64ct", "pem-rfc7468", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -5240,15 +5773,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" dependencies = [ "bcrypt-pbkdf", - "ed25519-dalek", + "ed25519-dalek 2.2.0", "num-bigint-dig", "p256", "p384", "p521", - "rand_core", + "rand_core 0.6.4", "rsa", "sec1", - "sha2", + "sha2 0.10.9", "signature 2.2.0", "ssh-cipher", "ssh-encoding", @@ -5691,7 +6224,7 @@ dependencies = [ "semver", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "syn 2.0.119", "tauri-utils", "thiserror 2.0.19", @@ -6093,6 +6626,15 @@ dependencies = [ "syn 3.0.2", ] +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + [[package]] name = "tiff" version = "0.11.3" @@ -6584,16 +7126,34 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + [[package]] name = "universal-hash" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "unsigned-varint" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" + [[package]] name = "url" version = "2.5.8" @@ -6863,6 +7423,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.5" @@ -7502,7 +8072,7 @@ checksum = "1ccf671d62d1bd0c913d9059e69bb4a6b51f7a4c899ab83c62d921e35f206053" dependencies = [ "defer-heavy", "log", - "rand", + "rand 0.8.7", "sync-ptr", "windows 0.58.0", ] @@ -7577,7 +8147,7 @@ dependencies = [ "once_cell", "percent-encoding", "raw-window-handle", - "sha2", + "sha2 0.10.9", "soup3", "tao-macros", "thiserror 2.0.19", @@ -7770,6 +8340,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "serde", + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] diff --git a/crates/radicle-tauri/src/commands/cob.rs b/crates/radicle-tauri/src/commands/cob.rs index 0737efaeb..7b9722afc 100644 --- a/crates/radicle-tauri/src/commands/cob.rs +++ b/crates/radicle-tauri/src/commands/cob.rs @@ -12,6 +12,7 @@ use crate::AppState; pub mod issue; pub mod job; pub mod patch; +pub mod release; #[tauri::command] pub async fn get_embed( diff --git a/crates/radicle-tauri/src/commands/cob/release.rs b/crates/radicle-tauri/src/commands/cob/release.rs new file mode 100644 index 000000000..edd964cea --- /dev/null +++ b/crates/radicle-tauri/src/commands/cob/release.rs @@ -0,0 +1,196 @@ +use radicle::git; +use radicle::identity; + +use radicle_types as types; +use radicle_types::error::Error; +use radicle_types::traits::release::{ReleaseFilter, Releases}; +use radicle_types::traits::release_mut::ReleasesMut; + +use tauri_plugin_dialog::DialogExt; + +use crate::AppState; +use crate::commands::blocking; + +#[tauri::command] +pub(crate) async fn list_releases( + ctx: tauri::State<'_, AppState>, + rid: identity::RepoId, + filter: Option, + skip: Option, + // None: return all releases, `skip` is ignored. + take: Option, +) -> Result>, Error> { + blocking(ctx, move |ctx| ctx.list_releases(rid, filter, skip, take)).await +} + +#[tauri::command] +pub(crate) async fn release_by_id( + ctx: tauri::State<'_, AppState>, + rid: identity::RepoId, + id: git::Oid, +) -> Result, Error> { + blocking(ctx, move |ctx| ctx.release_by_id(rid, id)).await +} + +#[tauri::command] +pub(crate) async fn release_counts( + ctx: tauri::State<'_, AppState>, + rid: identity::RepoId, +) -> Result { + blocking(ctx, move |ctx| ctx.release_counts(rid)).await +} + +#[tauri::command] +pub async fn compute_artifact_cid( + ctx: tauri::State<'_, AppState>, + path: std::path::PathBuf, +) -> Result { + blocking(ctx, move |ctx| ctx.compute_artifact_cid(path)).await +} + +#[tauri::command] +pub async fn create_or_open_release( + ctx: tauri::State<'_, AppState>, + rid: identity::RepoId, + oid: git::Oid, + tag: Option, +) -> Result { + blocking(ctx, move |ctx| ctx.create_or_open_release(rid, oid, tag)).await +} + +#[tauri::command] +pub async fn register_artifact( + ctx: tauri::State<'_, AppState>, + rid: identity::RepoId, + release_id: String, + cid: String, + name: String, + size_bytes: u64, +) -> Result<(), Error> { + blocking(ctx, move |ctx| { + ctx.register_artifact(rid, release_id, cid, name, size_bytes) + }) + .await +} + +#[tauri::command] +pub async fn attest_artifact( + ctx: tauri::State<'_, AppState>, + rid: identity::RepoId, + release_id: String, + cid: String, + path: std::path::PathBuf, +) -> Result<(), Error> { + blocking(ctx, move |ctx| { + ctx.attest_artifact(rid, release_id, cid, path) + }) + .await +} + +#[tauri::command] +pub async fn set_artifact_metadata( + ctx: tauri::State<'_, AppState>, + rid: identity::RepoId, + release_id: String, + cid: String, + key: String, + value: serde_json::Value, +) -> Result<(), Error> { + blocking(ctx, move |ctx| { + ctx.set_artifact_metadata(rid, release_id, cid, key, value) + }) + .await +} + +#[tauri::command] +pub async fn remove_artifact_metadata( + ctx: tauri::State<'_, AppState>, + rid: identity::RepoId, + release_id: String, + cid: String, + key: String, +) -> Result<(), Error> { + blocking(ctx, move |ctx| { + ctx.remove_artifact_metadata(rid, release_id, cid, key) + }) + .await +} + +#[tauri::command] +pub async fn add_artifact_location( + ctx: tauri::State<'_, AppState>, + rid: identity::RepoId, + release_id: String, + cid: String, + url: String, +) -> Result<(), Error> { + blocking(ctx, move |ctx| ctx.add_location(rid, release_id, cid, url)).await +} + +#[tauri::command] +pub async fn remove_artifact_location( + ctx: tauri::State<'_, AppState>, + rid: identity::RepoId, + release_id: String, + cid: String, + url: String, +) -> Result<(), Error> { + blocking(ctx, move |ctx| { + ctx.remove_location(rid, release_id, cid, url) + }) + .await +} + +#[tauri::command] +pub async fn redact_artifact( + ctx: tauri::State<'_, AppState>, + rid: identity::RepoId, + release_id: String, + cid: String, + reason: String, +) -> Result<(), Error> { + blocking(ctx, move |ctx| { + ctx.redact_artifact(rid, release_id, cid, reason) + }) + .await +} + +/// Pick one or more files to attach to a release. Returns an empty list when +/// the user cancels. The blocking dialog runs off the main thread, which the +/// plugin requires. +#[tauri::command] +pub async fn pick_artifact_files(app: tauri::AppHandle) -> Result, Error> { + let paths = tauri::async_runtime::spawn_blocking(move || { + app.dialog() + .file() + .blocking_pick_files() + .unwrap_or_default() + }) + .await?; + + Ok(paths + .into_iter() + .filter_map(|path| path.into_path().ok()) + .collect()) +} + +/// Pick a single directory to attach as a collection artifact. +#[tauri::command] +pub async fn pick_artifact_directory( + app: tauri::AppHandle, +) -> Result, Error> { + let path = + tauri::async_runtime::spawn_blocking(move || app.dialog().file().blocking_pick_folder()) + .await?; + + Ok(path.and_then(|path| path.into_path().ok())) +} + +#[tauri::command] +pub async fn delete_release( + ctx: tauri::State<'_, AppState>, + rid: identity::RepoId, + release_id: String, +) -> Result<(), Error> { + blocking(ctx, move |ctx| ctx.delete_release(rid, release_id)).await +} diff --git a/crates/radicle-tauri/src/lib.rs b/crates/radicle-tauri/src/lib.rs index 201b67cae..cb81288e9 100644 --- a/crates/radicle-tauri/src/lib.rs +++ b/crates/radicle-tauri/src/lib.rs @@ -83,6 +83,21 @@ pub fn run() { cob::patch::create_patch_review, cob::patch::rebuild_patch_cache, cob::patch::revisions_by_patch, + cob::release::list_releases, + cob::release::release_by_id, + cob::release::release_counts, + cob::release::compute_artifact_cid, + cob::release::create_or_open_release, + cob::release::register_artifact, + cob::release::attest_artifact, + cob::release::set_artifact_metadata, + cob::release::remove_artifact_metadata, + cob::release::add_artifact_location, + cob::release::remove_artifact_location, + cob::release::redact_artifact, + cob::release::delete_release, + cob::release::pick_artifact_files, + cob::release::pick_artifact_directory, cob::save_embed_by_bytes, cob::save_embed_by_path, cob::save_embed_to_disk, diff --git a/crates/radicle-types/Cargo.toml b/crates/radicle-types/Cargo.toml index 924dc138a..f720da2cf 100644 --- a/crates/radicle-types/Cargo.toml +++ b/crates/radicle-types/Cargo.toml @@ -15,6 +15,8 @@ log = { version = "0.4.22" } infer = { version = "0.19.0" } mime-infer = { version = "3.0.0" } radicle = { version = "0.25", features = ["tor", "i2p"] } +radicle-artifact = { version = "0.22" } +radicle-artifact-core = { version = "0.22" } radicle-job = { version = "0.6" } radicle-localtime = { version = "0.1.0", features = ["serde"] } radicle-surf = { version = "0.29.0", features = ["serde"] } diff --git a/crates/radicle-types/bindings/cob/release/Artifact.ts b/crates/radicle-types/bindings/cob/release/Artifact.ts new file mode 100644 index 000000000..87eaf5ea0 --- /dev/null +++ b/crates/radicle-types/bindings/cob/release/Artifact.ts @@ -0,0 +1,28 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Author } from "../Author"; +import type { Location } from "./Location"; +import type { Redaction } from "./Redaction"; + +/** + * A single file published as part of a release, addressed by its content id. + */ +export type Artifact = { + cid: string; + name: string; + /** + * Whether the content is a folder rather than a single file. + */ + directory: boolean; + author: Author; + /** + * Flattened across contributors: one entry per node/url pair. + */ + locations: Array; + attestations: Array; + redactions: Array; + /** + * Whether its author or a delegate redacted it, which hides it by default. + */ + redacted: boolean; + metadata: Record; +}; diff --git a/crates/radicle-types/bindings/cob/release/ArtifactDigest.ts b/crates/radicle-types/bindings/cob/release/ArtifactDigest.ts new file mode 100644 index 000000000..c07e0982f --- /dev/null +++ b/crates/radicle-types/bindings/cob/release/ArtifactDigest.ts @@ -0,0 +1,20 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A locally computed content id and size for a file or directory staged for + * release, before it is registered on the COB. + */ +export type ArtifactDigest = { + cid: string; + sizeBytes: number; + /** + * Files this covers: 1 for a file, the walked count for a directory. Lets + * the app say what a directory is about to publish before it publishes it. + */ + fileCount: number; + /** + * Whether the path is a directory, which is registered as a single + * artifact named after the folder. + */ + directory: boolean; +}; diff --git a/crates/radicle-types/bindings/cob/release/Location.ts b/crates/radicle-types/bindings/cob/release/Location.ts new file mode 100644 index 000000000..e176d346f --- /dev/null +++ b/crates/radicle-types/bindings/cob/release/Location.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Author } from "../Author"; + +/** + * A place an artifact can be fetched from, contributed by a single node. + */ +export type Location = { user: Author; url: string }; diff --git a/crates/radicle-types/bindings/cob/release/Redaction.ts b/crates/radicle-types/bindings/cob/release/Redaction.ts new file mode 100644 index 000000000..242cace6d --- /dev/null +++ b/crates/radicle-types/bindings/cob/release/Redaction.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Author } from "../Author"; + +/** + * A node flagging an artifact, with the reason it gave. + */ +export type Redaction = { user: Author; reason: string }; diff --git a/crates/radicle-types/bindings/cob/release/Release.ts b/crates/radicle-types/bindings/cob/release/Release.ts new file mode 100644 index 000000000..dd4b98f2f --- /dev/null +++ b/crates/radicle-types/bindings/cob/release/Release.ts @@ -0,0 +1,19 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Author } from "../Author"; +import type { Artifact } from "./Artifact"; + +/** + * A release, keyed by a commit and optionally an annotated tag. The COB has + * no title of its own: `title` and `tagName` are resolved from the tag or + * commit message, and are absent when they cannot be read. + */ +export type Release = { + id: string; + oid: string; + tag?: string; + tagName?: string; + title?: string; + createdAt: number; + creator: Author; + artifacts: Array; +}; diff --git a/crates/radicle-types/bindings/cob/release/ReleaseCounts.ts b/crates/radicle-types/bindings/cob/release/ReleaseCounts.ts new file mode 100644 index 000000000..56d805cc3 --- /dev/null +++ b/crates/radicle-types/bindings/cob/release/ReleaseCounts.ts @@ -0,0 +1,13 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Release counts bucketed by creator trust and artifact redaction. A redacted + * release has every artifact redacted by a trusted party. A release with no + * artifacts is not redacted. + */ +export type ReleaseCounts = { + delegate: number; + delegateRedacted: number; + other: number; + otherRedacted: number; +}; diff --git a/crates/radicle-types/bindings/cob/release/ReleaseFilter.ts b/crates/radicle-types/bindings/cob/release/ReleaseFilter.ts new file mode 100644 index 000000000..13f038bdf --- /dev/null +++ b/crates/radicle-types/bindings/cob/release/ReleaseFilter.ts @@ -0,0 +1,16 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ReleaseScope } from "./ReleaseScope"; + +/** + * Which side of the release view the caller asks for. + */ +export type ReleaseFilter = { + /** + * Show releases and artifacts by delegates, or by everyone else. + */ + scope: ReleaseScope; + /** + * Include artifacts redacted by their author or a delegate. + */ + showRedacted: boolean; +}; diff --git a/crates/radicle-types/bindings/cob/release/ReleaseScope.ts b/crates/radicle-types/bindings/cob/release/ReleaseScope.ts new file mode 100644 index 000000000..316a93487 --- /dev/null +++ b/crates/radicle-types/bindings/cob/release/ReleaseScope.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * The two disjoint author scopes of the release view. No local user is + * trusted, so trusted means delegates. + */ +export type ReleaseScope = "trusted" | "untrusted"; diff --git a/crates/radicle-types/bindings/repo/Tag.ts b/crates/radicle-types/bindings/repo/Tag.ts index 53b9faae8..629618ba3 100644 --- a/crates/radicle-types/bindings/repo/Tag.ts +++ b/crates/radicle-types/bindings/repo/Tag.ts @@ -3,6 +3,12 @@ import type { Tagger } from "./Tagger"; export type Tag = { oid: string; + /** + * Oid of the annotated tag object itself, as opposed to the commit it + * peels to. Absent for lightweight tags. A release records this so it can + * resolve its own title and tag name. + */ + tagOid?: string; /** * Tagger time for annotated tags, otherwise the commit time of the * tagged commit. Seconds since epoch. diff --git a/crates/radicle-types/src/cobs.rs b/crates/radicle-types/src/cobs.rs index 9f6b3da4b..ae58dd6b6 100644 --- a/crates/radicle-types/src/cobs.rs +++ b/crates/radicle-types/src/cobs.rs @@ -11,6 +11,7 @@ use radicle::node::{Alias, AliasStore}; pub mod diff; pub mod issue; pub mod job; +pub mod release; pub mod repo; pub mod stream; pub mod thread; diff --git a/crates/radicle-types/src/cobs/release.rs b/crates/radicle-types/src/cobs/release.rs new file mode 100644 index 000000000..9c3694800 --- /dev/null +++ b/crates/radicle-types/src/cobs/release.rs @@ -0,0 +1,224 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use radicle::cob; +use radicle::git; +use radicle::identity::Did; +use radicle::node::AliasStore; +use radicle::storage::git::Repository; +use serde::Serialize; +use ts_rs::TS; + +use radicle_artifact::display::{CommitTitle, TagName}; +use radicle_artifact_core::cid::{ArtifactKind, artifact_kind}; + +use crate::cobs; + +/// A locally computed content id and size for a file or directory staged for +/// release, before it is registered on the COB. +#[derive(Clone, Serialize, TS, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +#[ts(export_to = "cob/release/")] +pub struct ArtifactDigest { + pub cid: String, + #[ts(type = "number")] + pub size_bytes: u64, + /// Files this covers: 1 for a file, the walked count for a directory. Lets + /// the app say what a directory is about to publish before it publishes it. + #[ts(type = "number")] + pub file_count: u64, + /// Whether the path is a directory, which is registered as a single + /// artifact named after the folder. + pub directory: bool, +} + +/// A place an artifact can be fetched from, contributed by a single node. +#[derive(Clone, Serialize, TS, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +#[ts(export_to = "cob/release/")] +pub struct Location { + pub user: cobs::Author, + pub url: String, +} + +/// A node flagging an artifact, with the reason it gave. +#[derive(Clone, Serialize, TS, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +#[ts(export_to = "cob/release/")] +pub struct Redaction { + pub user: cobs::Author, + pub reason: String, +} + +/// A single file published as part of a release, addressed by its content id. +#[derive(Clone, Serialize, TS, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +#[ts(export_to = "cob/release/")] +pub struct Artifact { + pub cid: String, + pub name: String, + /// Whether the content is a folder rather than a single file. + pub directory: bool, + pub author: cobs::Author, + /// Flattened across contributors: one entry per node/url pair. + pub locations: Vec, + pub attestations: Vec, + pub redactions: Vec, + /// Whether its author or a delegate redacted it, which hides it by default. + pub redacted: bool, + #[ts(type = "Record")] + pub metadata: BTreeMap, +} + +impl Artifact { + pub fn new( + cid: &radicle_artifact::Cid, + artifact: &radicle_artifact::Artifact, + delegates: &BTreeSet, + aliases: &impl AliasStore, + ) -> Self { + Self { + cid: cid.to_string(), + name: artifact.name().to_string(), + directory: matches!(artifact_kind(cid), Ok(ArtifactKind::Collection)), + author: cobs::Author::new(artifact.author(), aliases), + locations: artifact + .locations() + .iter() + .flat_map(|(did, urls)| { + urls.iter().map(move |url| Location { + user: cobs::Author::new(did, aliases), + url: url.to_string(), + }) + }) + .collect(), + attestations: artifact + .attestations() + .iter() + .map(|did| cobs::Author::new(did, aliases)) + .collect(), + redactions: artifact + .redactions() + .iter() + .map(|(did, reason)| Redaction { + user: cobs::Author::new(did, aliases), + reason: reason.clone(), + }) + .collect(), + redacted: artifact.is_redacted_by_trusted(delegates), + metadata: artifact.trusted_metadata(delegates), + } + } +} + +/// Release counts bucketed by creator trust and artifact redaction. A redacted +/// release has every artifact redacted by a trusted party. A release with no +/// artifacts is not redacted. +#[derive(Clone, Copy, Serialize, TS, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +#[ts(export_to = "cob/release/")] +pub struct ReleaseCounts { + #[ts(type = "number")] + pub delegate: usize, + #[ts(type = "number")] + pub delegate_redacted: usize, + #[ts(type = "number")] + pub other: usize, + #[ts(type = "number")] + pub other_redacted: usize, +} + +impl From for ReleaseCounts { + fn from(counts: radicle_artifact::ReleaseCounts) -> Self { + Self { + delegate: counts.delegate, + delegate_redacted: counts.delegate_redacted, + other: counts.other, + other_redacted: counts.other_redacted, + } + } +} + +/// The two disjoint author scopes of the release view. No local user is +/// trusted, so trusted means delegates. +#[derive(Clone, Copy, Default, Serialize, serde::Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +#[ts(export_to = "cob/release/")] +pub enum ReleaseScope { + #[default] + Trusted, + Untrusted, +} + +/// Which side of the release view the caller asks for. +#[derive(Clone, Copy, Default, Serialize, serde::Deserialize, TS)] +#[serde(rename_all = "camelCase", default)] +#[ts(export)] +#[ts(export_to = "cob/release/")] +pub struct ReleaseFilter { + /// Show releases and artifacts by delegates, or by everyone else. + pub scope: ReleaseScope, + /// Include artifacts redacted by their author or a delegate. + pub show_redacted: bool, +} + +/// A release, keyed by a commit and optionally an annotated tag. The COB has +/// no title of its own: `title` and `tagName` are resolved from the tag or +/// commit message, and are absent when they cannot be read. +#[derive(Clone, Serialize, TS, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +#[ts(export_to = "cob/release/")] +pub struct Release { + #[ts(as = "String")] + pub id: git::Oid, + #[ts(as = "String")] + pub oid: git::Oid, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(as = "Option", optional)] + pub tag: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub tag_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[ts(type = "number")] + pub created_at: cob::Timestamp, + pub creator: cobs::Author, + pub artifacts: Vec, +} + +impl Release { + /// Build a release from its COB, with `artifacts` already narrowed to the + /// ones visible under the caller's filter. + pub fn new( + id: radicle_artifact::ReleaseId, + release: &radicle_artifact::Release, + repo: &Repository, + aliases: &impl AliasStore, + artifacts: Vec, + ) -> Self { + let title = release + .tag() + .and_then(|tag| repo.title(tag)) + .or_else(|| repo.title(release.oid())); + let tag_name = release.tag().and_then(|tag| repo.tag_name(tag)); + + Self { + id: id.oid(), + oid: *release.oid(), + tag: release.tag().copied(), + tag_name, + title, + created_at: release.timestamp(), + creator: cobs::Author::new(release.creator(), aliases), + artifacts, + } + } +} diff --git a/crates/radicle-types/src/error.rs b/crates/radicle-types/src/error.rs index 85c1fa240..403cbe7b2 100644 --- a/crates/radicle-types/src/error.rs +++ b/crates/radicle-types/src/error.rs @@ -154,6 +154,14 @@ pub enum Error { #[error("revision not found: {0}")] RevisionNotFound(String), + /// Commit not found in the repository. + #[error("commit not found: {0}")] + CommitNotFound(radicle::git::Oid), + + /// A location URL that peers could not fetch an artifact from. + #[error("invalid location: {0}")] + InvalidLocation(String), + /// Surf error. #[error(transparent)] Surf(#[from] radicle_surf::Error), @@ -189,6 +197,30 @@ pub enum Error { /// Serde JSON error. #[error(transparent)] SerdeJSON(#[from] serde_json::error::Error), + + /// Release creation error. + #[error(transparent)] + ReleaseCreate(#[from] radicle_artifact::error::Create), + + /// Artifact metadata error. + #[error(transparent)] + ArtifactMetadata(#[from] radicle_artifact::error::Metadata), + + /// Artifact redaction error. + #[error(transparent)] + ArtifactRedact(#[from] radicle_artifact::error::Redact), + + /// Artifact content id, key or I/O error. + #[error(transparent)] + ArtifactCore(#[from] radicle_artifact_core::Error), + + /// Object id parse error. + #[error(transparent)] + ParseObjectId(#[from] radicle::cob::object::ParseObjectId), + + /// URL parse error. + #[error(transparent)] + UrlParse(#[from] url::ParseError), } impl Error { @@ -217,6 +249,11 @@ impl Error { } Error::FileTooLarge(_) => "PayloadError.TooLarge", Error::ReviewExists => "PatchError.ReviewExists", + Error::CommitNotFound(_) => "RepoError.CommitNotFound", + Error::InvalidLocation(_) => "ArtifactError.InvalidLocation", + Error::ArtifactCore(radicle_artifact_core::Error::CidMismatch { .. }) => { + "ArtifactError.CidMismatch" + } _ => "UnknownError", } } diff --git a/crates/radicle-types/src/lib.rs b/crates/radicle-types/src/lib.rs index 93627f8da..48bc600a6 100644 --- a/crates/radicle-types/src/lib.rs +++ b/crates/radicle-types/src/lib.rs @@ -5,6 +5,8 @@ use traits::inbox::Inbox; use traits::issue::{Issues, IssuesMut}; use traits::job::Jobs; use traits::patch::{Patches, PatchesMut}; +use traits::release::Releases; +use traits::release_mut::ReleasesMut; use traits::repo::Repo; use traits::thread::Thread; @@ -37,6 +39,8 @@ impl IssuesMut for AppState {} impl Jobs for AppState {} impl Patches for AppState {} impl PatchesMut for AppState {} +impl Releases for AppState {} +impl ReleasesMut for AppState {} impl Profile for AppState { fn profile(&self) -> radicle::Profile { self.profile.clone() diff --git a/crates/radicle-types/src/repo.rs b/crates/radicle-types/src/repo.rs index f94cfa7f7..2894470f9 100644 --- a/crates/radicle-types/src/repo.rs +++ b/crates/radicle-types/src/repo.rs @@ -217,6 +217,12 @@ pub struct Tagger { pub struct Tag { #[ts(as = "String")] pub oid: git::Oid, + /// Oid of the annotated tag object itself, as opposed to the commit it + /// peels to. Absent for lightweight tags. A release records this so it can + /// resolve its own title and tag name. + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(as = "Option", optional)] + pub tag_oid: Option, /// Tagger time for annotated tags, otherwise the commit time of the /// tagged commit. Seconds since epoch. #[ts(type = "number")] diff --git a/crates/radicle-types/src/traits.rs b/crates/radicle-types/src/traits.rs index eff99ee4d..37cb4c58e 100644 --- a/crates/radicle-types/src/traits.rs +++ b/crates/radicle-types/src/traits.rs @@ -16,6 +16,8 @@ pub mod inbox; pub mod issue; pub mod job; pub mod patch; +pub mod release; +pub mod release_mut; pub mod repo; pub mod thread; diff --git a/crates/radicle-types/src/traits/release.rs b/crates/radicle-types/src/traits/release.rs new file mode 100644 index 000000000..f895beeee --- /dev/null +++ b/crates/radicle-types/src/traits/release.rs @@ -0,0 +1,326 @@ +use std::collections::BTreeSet; + +use radicle::identity::Did; +use radicle::storage::ReadStorage; +use radicle::{git, identity}; + +use radicle_artifact::trust::{Scope, Trust}; +use radicle_artifact::{Filters, ReleaseId, Releases as ArtifactStore, cache_db_path}; + +use crate::cobs; +pub use crate::cobs::release::{ReleaseFilter, ReleaseScope}; +use crate::error::Error; +use crate::traits::Profile; + +impl ReleaseFilter { + /// The crate's visibility rules for this view. No local user is trusted, + /// so the default view matches the `delegate` bucket of `counts()`. + fn filters(self, delegates: &BTreeSet) -> Filters<'_> { + Filters { + trust: Trust::new(delegates, None), + scope: match self.scope { + ReleaseScope::Trusted => Scope::Trusted, + ReleaseScope::Untrusted => Scope::Untrusted, + }, + include_redacted: self.show_redacted, + } + } +} + +pub trait Releases: Profile { + /// List a repository's releases, newest first. + /// + /// Scoped to releases created by a delegate and artifacts authored by a + /// delegate, or with an untrusted `filter` to those by non-delegates, so + /// the two scopes never overlap. Artifacts redacted by a trusted party are + /// hidden unless `filter` shows them. A release with no artifact in scope + /// shows every author's. A release whose artifacts were all redacted is + /// hidden with them; a release with no artifacts is shown. This is the view + /// `rad-artifact list` gives. Without `take` the full list is returned and + /// `skip` is ignored. + fn list_releases( + &self, + rid: identity::RepoId, + filter: Option, + skip: Option, + take: Option, + ) -> Result>, Error> { + let profile = self.profile(); + let repo = profile.storage.repository(rid)?; + let aliases = profile.aliases(); + let filter = filter.unwrap_or_default(); + + // Read through the SQLite cache; it self-warms on read and is shared + // with other release reads on this node. `list` is sorted newest + // first and lazy, so a page reads only the rows up to its end. + let store = ArtifactStore::open_cached(&repo, cache_db_path(profile.cobs()))?; + let filters = filter.filters(store.delegates()); + let releases = store + .list()? + .filter_map(Result::ok) + .filter(|(_, release)| filters.shows_release(release)); + + // With no artifact by an author in scope, fall back to every author, as + // the release page does, so a teaser never counts fewer artifacts than + // its page lists. A redacted artifact in scope keeps the scope. + let fallback = Filters { + scope: Scope::All, + ..filters + }; + let summary = |(id, release): (radicle::cob::ObjectId, radicle_artifact::Release)| { + let has_trusted = release + .artifacts() + .values() + .any(|artifact| filters.trust.admits(filters.scope, artifact.author())); + let artifacts = if has_trusted { filters } else { fallback } + .artifacts(&release) + .map(|(cid, artifact)| { + cobs::release::Artifact::new(cid, artifact, store.delegates(), &aliases) + }) + .collect::>(); + + cobs::release::Release::new(ReleaseId::from(id), &release, &repo, &aliases, artifacts) + }; + + match take { + None => Ok(cobs::PaginatedQuery { + cursor: 0, + more: false, + content: releases.map(summary).collect::>(), + }), + Some(take) => { + let cursor = skip.unwrap_or(0); + let mut content = releases + .skip(cursor) + .take(take + 1) + .map(summary) + .collect::>(); + let more = content.len() > take; + content.truncate(take); + + Ok(cobs::PaginatedQuery { + cursor, + more, + content, + }) + } + } + } + + /// Get a single release by id, with all of its artifacts. Filtering is left + /// to the caller so a release reached by a direct link is never empty. + fn release_by_id( + &self, + rid: identity::RepoId, + id: git::Oid, + ) -> Result, Error> { + let profile = self.profile(); + let repo = profile.storage.repository(rid)?; + let aliases = profile.aliases(); + + let store = ArtifactStore::open_cached(&repo, cache_db_path(profile.cobs()))?; + let release = store.get(&ReleaseId::from(id))?; + + Ok(release.map(|release| { + let artifacts = release + .artifacts() + .iter() + .map(|(cid, artifact)| { + cobs::release::Artifact::new(cid, artifact, store.delegates(), &aliases) + }) + .collect::>(); + + cobs::release::Release::new(ReleaseId::from(id), &release, &repo, &aliases, artifacts) + })) + } + + /// Number of releases in a repository, bucketed the same way the default + /// list filters them. + fn release_counts(&self, rid: identity::RepoId) -> Result { + let profile = self.profile(); + let repo = profile.storage.repository(rid)?; + let store = ArtifactStore::open_cached(&repo, cache_db_path(profile.cobs()))?; + + Ok(store.counts()?.into()) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod test { + use std::str::FromStr; + + use radicle::crypto::{Seed, SigningKey}; + use radicle::storage::ReadStorage; + use radicle::test::fixtures; + + use radicle_artifact::{Cid, Releases as ArtifactStore}; + + use super::Releases; + use crate::cobs::release::{ReleaseFilter, ReleaseScope}; + use crate::{AppState, test}; + + const CID: &str = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"; + const OTHER_CID: &str = "bafkr4ihjtgc6jaulcccny3jhc3ezxfjdgnp4vvt23nf6qqlrhpk764ufbu"; + + /// Count the artifacts the default list shows for a delegate release + /// holding one non-delegate artifact, after `setup` adds to it. + fn listed_artifacts( + setup: impl FnOnce( + &mut radicle_artifact::ReleaseMut<'_, '_, radicle::storage::git::Repository>, + &SigningKey, + ), + ) -> usize { + let tmp = tempfile::tempdir().unwrap(); + let profile = test::profile(&tmp.path().join("home"), [0xff; 32]); + let signer = SigningKey::from_seed(Seed::new([0xff; 32])); + let other = SigningKey::from_seed(Seed::new([0xee; 32])); + let (rid, _, _, head) = + fixtures::project(tmp.path().join("project"), &profile.storage, &signer).unwrap(); + { + let repo = profile.storage.repository(rid).unwrap(); + let mut releases = ArtifactStore::open(&repo).unwrap(); + let mut release = releases + .create(radicle::git::Oid::from(head), None, &signer) + .unwrap(); + release + .register_artifact(Cid::from_str(OTHER_CID).unwrap(), "eve".into(), &other) + .unwrap(); + setup(&mut release, &signer); + } + + let state = AppState { profile }; + let list = state.list_releases(rid, None, None, None).unwrap(); + list.content[0].artifacts.len() + } + + #[test] + fn list_falls_back_to_every_author_without_delegate_artifact() { + assert_eq!(listed_artifacts(|_, _| {}), 1); + } + + #[test] + fn list_keeps_delegate_scope_with_redacted_delegate_artifact() { + let count = listed_artifacts(|release, signer| { + let cid = Cid::from_str(CID).unwrap(); + release + .register_artifact(cid, "bin".into(), signer) + .unwrap(); + release.redact(cid, "bad build".into(), signer).unwrap(); + }); + assert_eq!(count, 0); + } + + #[test] + fn list_pages_through_releases() { + let tmp = tempfile::tempdir().unwrap(); + let profile = test::profile(&tmp.path().join("home"), [0xff; 32]); + let signer = SigningKey::from_seed(Seed::new([0xff; 32])); + let (rid, _, _, head) = + fixtures::project(tmp.path().join("project"), &profile.storage, &signer).unwrap(); + { + let repo = profile.storage.repository(rid).unwrap(); + let mut releases = ArtifactStore::open(&repo).unwrap(); + for seed in [0xaa, 0xbb, 0xcc] { + let other = SigningKey::from_seed(Seed::new([seed; 32])); + releases + .create(radicle::git::Oid::from(head), None, &other) + .unwrap(); + } + } + let state = AppState { profile }; + let filter = ReleaseFilter { + scope: ReleaseScope::Untrusted, + show_redacted: false, + }; + let page = |skip, take| state.list_releases(rid, Some(filter), skip, take).unwrap(); + + let all = page(None, None); + assert_eq!((all.content.len(), all.more), (3, false)); + + let first = page(Some(0), Some(2)); + let second = page(Some(2), Some(2)); + assert_eq!((first.cursor, first.more), (0, true)); + assert_eq!((second.cursor, second.more), (2, false)); + let ids = |pages: &[&[crate::cobs::release::Release]]| { + pages + .iter() + .flat_map(|p| p.iter().map(|r| r.id)) + .collect::>() + }; + assert_eq!( + ids(&[&first.content, &second.content]), + ids(&[&all.content]) + ); + + let exact = page(Some(0), Some(3)); + assert_eq!((exact.content.len(), exact.more), (3, false)); + } + + #[test] + fn list_scopes_split_delegates_from_others() { + let tmp = tempfile::tempdir().unwrap(); + let profile = test::profile(&tmp.path().join("home"), [0xff; 32]); + let signer = SigningKey::from_seed(Seed::new([0xff; 32])); + let other = SigningKey::from_seed(Seed::new([0xee; 32])); + let (rid, _, _, head) = + fixtures::project(tmp.path().join("project"), &profile.storage, &signer).unwrap(); + let (ours, theirs) = { + let repo = profile.storage.repository(rid).unwrap(); + let mut releases = ArtifactStore::open(&repo).unwrap(); + let oid = radicle::git::Oid::from(head); + let ours = releases.create(oid, None, &signer).unwrap().id().oid(); + let theirs = releases.create(oid, None, &other).unwrap().id().oid(); + (ours, theirs) + }; + + let state = AppState { profile }; + let listed = |scope| { + let filter = ReleaseFilter { + scope, + show_redacted: false, + }; + state + .list_releases(rid, Some(filter), None, None) + .unwrap() + .content + .into_iter() + .map(|r| r.id) + .collect::>() + }; + + assert_eq!(listed(ReleaseScope::Trusted), [ours]); + assert_eq!(listed(ReleaseScope::Untrusted), [theirs]); + } + + #[test] + fn release_counts_split_delegates_from_others() { + let tmp = tempfile::tempdir().unwrap(); + let profile = test::profile(&tmp.path().join("home"), [0xff; 32]); + let signer = SigningKey::from_seed(Seed::new([0xff; 32])); + let other = SigningKey::from_seed(Seed::new([0xee; 32])); + let (rid, _, _, head) = + fixtures::project(tmp.path().join("project"), &profile.storage, &signer).unwrap(); + { + let repo = profile.storage.repository(rid).unwrap(); + let mut releases = ArtifactStore::open(&repo).unwrap(); + let oid = radicle::git::Oid::from(head); + releases.create(oid, None, &signer).unwrap(); + let mut theirs = releases.create(oid, None, &other).unwrap(); + let cid = Cid::from_str(OTHER_CID).unwrap(); + theirs.register_artifact(cid, "eve".into(), &other).unwrap(); + theirs.redact(cid, "oops".into(), &other).unwrap(); + } + + let counts = AppState { profile }.release_counts(rid).unwrap(); + assert_eq!( + ( + counts.delegate, + counts.delegate_redacted, + counts.other, + counts.other_redacted + ), + (1, 0, 0, 1) + ); + } +} diff --git a/crates/radicle-types/src/traits/release_mut.rs b/crates/radicle-types/src/traits/release_mut.rs new file mode 100644 index 000000000..5575d3608 --- /dev/null +++ b/crates/radicle-types/src/traits/release_mut.rs @@ -0,0 +1,641 @@ +use std::path::PathBuf; +use std::str::FromStr; + +use radicle::identity::{self, Did}; +use radicle::profile::Signer; +use radicle::storage::ReadStorage; +use radicle::storage::git::Repository; +use url::Url; + +use radicle_artifact::{Cid, ReleaseId, ReleaseMut, Releases as ArtifactStore}; +use radicle_artifact_core::cid as cid_utils; +use radicle_artifact_core::keys::EndpointId; + +use crate::cobs; +use crate::error::Error; +use crate::traits::Profile; +use crate::traits::release::Releases; + +/// Parse a content id, folding the multiformats parse error into our own so +/// the rest of the app need not depend on the `cid` crate. +pub(crate) fn parse_cid(cid: &str) -> Result { + Cid::from_str(cid).map_err(|err| radicle_artifact_core::Error::Cid(err.to_string()).into()) +} + +/// Parse a location URL, rejecting ones peers could not fetch from, as +/// `rad-artifact location add` does, so they are never signed into the COB. +fn parse_location(url: &str) -> Result { + let url = Url::parse(url.trim()).map_err(|err| Error::InvalidLocation(err.to_string()))?; + if EndpointId::is_legacy_endpoint_url(&url) { + return Err(Error::InvalidLocation( + "the `iroh://` scheme was renamed to `radiroh://`".into(), + )); + } + if EndpointId::is_endpoint_url(&url) { + EndpointId::from_url(&url).map_err(|err| Error::InvalidLocation(err.to_string()))?; + } + + Ok(url) +} + +/// Open a release for writing and apply `f` to the artifact `cid` on it, +/// signed by the local user. +fn update_artifact( + ctx: &(impl Profile + ?Sized), + rid: identity::RepoId, + release_id: &str, + cid: &str, + f: impl FnOnce(&mut ReleaseMut<'_, '_, Repository>, Cid, &Signer) -> Result<(), Error>, +) -> Result<(), Error> { + let profile = ctx.profile(); + let signer = profile.signer()?; + let repo = profile.storage.repository(rid)?; + + let id = ReleaseId::from_str(release_id)?; + let cid = parse_cid(cid)?; + + let mut releases = ArtifactStore::open(&repo)?; + let mut release = releases.get_mut(&id)?; + f(&mut release, cid, &signer) +} + +pub trait ReleasesMut: Releases { + /// Content-address a file or directory on disk without involving the + /// artifact node, so a release can be assembled while the node is down. + /// + /// Files hash as a single blob; directories hash as a collection over a + /// canonical walk. The size is returned alongside so the caller can + /// register the artifact and its size hint in one signed entry. + fn compute_artifact_cid(&self, path: PathBuf) -> Result { + let directory = path.is_dir(); + let cid = if directory { + cid_utils::compute_content_id(&path)? + } else { + cid_utils::compute_blob_cid(&path)? + }; + let file_count = if directory { + cid_utils::canonical_walk(&path)?.len() as u64 + } else { + 1 + }; + + Ok(cobs::release::ArtifactDigest { + cid: cid.to_string(), + size_bytes: cid_utils::compute_size_from_path(&path)?, + file_count, + directory, + }) + } + + /// Find the release for a commit, or create one. Returns the release id. + /// + /// Idempotent: re-running against the same commit reuses the existing + /// release rather than creating a second one. As with `rad-artifact + /// release create`, only a release the local user created is reused, so + /// artifacts never land under another peer's release. With `tag`, only a + /// release carrying that exact tag is reused; without it, the newest one + /// for the commit is. Pass `None` for lightweight tags and bare commits, + /// since the store accepts only an annotated tag that peels to `oid`. + fn create_or_open_release( + &self, + rid: identity::RepoId, + oid: radicle::git::Oid, + tag: Option, + ) -> Result { + let profile = self.profile(); + let signer = profile.signer()?; + let repo = profile.storage.repository(rid)?; + + if repo.backend.find_commit(oid.into()).is_err() { + return Err(Error::CommitNotFound(oid)); + } + + let local = Did::from(profile.public_key); + + let mut releases = ArtifactStore::open(&repo)?; + + let existing = releases + .find_by_commit(oid)? + .collect::, _>>()? + .into_iter() + .filter(|(_, release)| { + release.creator() == &local && tag.is_none_or(|tag| release.tag() == Some(&tag)) + }) + .max_by_key(|(_, release)| release.timestamp()) + .map(|(id, _)| id); + + let id = match existing { + Some(id) => id, + None => *releases.create(oid, tag, &signer)?.id(), + }; + + Ok(id.to_string()) + } + + /// Register an artifact under a release, recording its size hint in the + /// same signed entry. + fn register_artifact( + &self, + rid: identity::RepoId, + release_id: String, + cid: String, + name: String, + size_bytes: u64, + ) -> Result<(), Error> { + update_artifact(self, rid, &release_id, &cid, |release, cid, signer| { + release.register_artifact_with_size(cid, name, size_bytes, signer)?; + Ok(()) + }) + } + + /// Announce a place an artifact can be fetched from. + fn add_location( + &self, + rid: identity::RepoId, + release_id: String, + cid: String, + url: String, + ) -> Result<(), Error> { + let url = parse_location(&url)?; + update_artifact(self, rid, &release_id, &cid, |release, cid, signer| { + release.add_location(cid, url, signer)?; + Ok(()) + }) + } + + /// Withdraw a location this node previously announced. + fn remove_location( + &self, + rid: identity::RepoId, + release_id: String, + cid: String, + url: String, + ) -> Result<(), Error> { + let url = Url::parse(&url)?; + update_artifact(self, rid, &release_id, &cid, |release, cid, signer| { + release.remove_location(cid, url, signer)?; + Ok(()) + }) + } + + /// Vouch for an artifact by reproducing it: hash a local build at `path` + /// and sign an attestation only when it arrives at the same content id. + fn attest_artifact( + &self, + rid: identity::RepoId, + release_id: String, + cid: String, + path: PathBuf, + ) -> Result<(), Error> { + let expected = parse_cid(&cid)?; + let actual = parse_cid(&self.compute_artifact_cid(path)?.cid)?; + if actual != expected { + return Err(radicle_artifact_core::Error::CidMismatch { + expected: expected.to_string(), + actual: actual.to_string(), + } + .into()); + } + + update_artifact(self, rid, &release_id, &cid, |release, cid, signer| { + release.attest(cid, signer)?; + Ok(()) + }) + } + + /// Set a free-form metadata key on an artifact. + fn set_artifact_metadata( + &self, + rid: identity::RepoId, + release_id: String, + cid: String, + key: String, + value: serde_json::Value, + ) -> Result<(), Error> { + update_artifact(self, rid, &release_id, &cid, |release, cid, signer| { + release.set_metadata(cid, key, value, signer)?; + Ok(()) + }) + } + + /// Remove a metadata key from an artifact. + fn remove_artifact_metadata( + &self, + rid: identity::RepoId, + release_id: String, + cid: String, + key: String, + ) -> Result<(), Error> { + update_artifact(self, rid, &release_id, &cid, |release, cid, signer| { + release.remove_metadata(cid, key, signer)?; + Ok(()) + }) + } + + /// Flag an artifact as one that should no longer be fetched. + fn redact_artifact( + &self, + rid: identity::RepoId, + release_id: String, + cid: String, + reason: String, + ) -> Result<(), Error> { + update_artifact(self, rid, &release_id, &cid, |release, cid, signer| { + release.redact(cid, reason, signer)?; + Ok(()) + }) + } + + /// Remove the local user's ref to a release. + /// + /// The release disappears once no peer has a ref to it. Actions other + /// peers built on stay visible, so a release others added to remains. + fn delete_release(&self, rid: identity::RepoId, release_id: String) -> Result<(), Error> { + let profile = self.profile(); + let signer = profile.signer()?; + let repo = profile.storage.repository(rid)?; + + let id = ReleaseId::from_str(&release_id)?; + + let mut releases = ArtifactStore::open(&repo)?; + releases.remove(&id, &signer)?; + + Ok(()) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +pub(crate) mod test { + use std::str::FromStr; + + use radicle::crypto::{Seed, Signer, SigningKey}; + use radicle::identity::RepoId; + use radicle::storage::ReadStorage; + use radicle::test::fixtures; + + use radicle_artifact::Releases as ArtifactStore; + + use super::ReleasesMut; + use crate::cobs::release::Release; + use crate::traits::release::Releases; + use crate::{AppState, test}; + + #[test] + fn create_or_open_release_skips_other_creators() { + let tmp = tempfile::tempdir().unwrap(); + let profile = test::profile(&tmp.path().join("home"), [0xff; 32]); + let signer = SigningKey::from_seed(Seed::new([0xff; 32])); + let (rid, _, _, head) = + fixtures::project(tmp.path().join("project"), &profile.storage, &signer).unwrap(); + let oid = radicle::git::Oid::from(head); + + // Another peer releases the same commit first. + let other = SigningKey::from_seed(Seed::new([0xee; 32])); + let repo = profile.storage.repository(rid).unwrap(); + let theirs = *ArtifactStore::open(&repo) + .unwrap() + .create(oid, None, &other) + .unwrap() + .id(); + + let state = AppState { profile }; + let ours = state.create_or_open_release(rid, oid, None).unwrap(); + + assert_ne!(ours, theirs.to_string()); + assert_eq!(state.create_or_open_release(rid, oid, None).unwrap(), ours); + } + + #[test] + fn create_or_open_release_rejects_unknown_commit() { + let tmp = tempfile::tempdir().unwrap(); + let profile = test::profile(&tmp.path().join("home"), [0xff; 32]); + let signer = SigningKey::from_seed(Seed::new([0xff; 32])); + let (rid, _, _, _) = + fixtures::project(tmp.path().join("project"), &profile.storage, &signer).unwrap(); + let unknown = + radicle::git::Oid::from_str("cafecafecafecafecafecafecafecafecafecafe").unwrap(); + + let state = AppState { profile }; + let err = state + .create_or_open_release(rid, unknown, None) + .unwrap_err(); + + assert_eq!(err.code(), "RepoError.CommitNotFound"); + } + + /// A release on a fresh project with one registered artifact. + pub(crate) fn release_with_artifact( + tmp: &std::path::Path, + ) -> (AppState, RepoId, String, String) { + let profile = test::profile(&tmp.join("home"), [0xff; 32]); + let signer = SigningKey::from_seed(Seed::new([0xff; 32])); + let (rid, _, _, head) = + fixtures::project(tmp.join("project"), &profile.storage, &signer).unwrap(); + + let state = AppState { profile }; + let id = state + .create_or_open_release(rid, radicle::git::Oid::from(head), None) + .unwrap(); + let file = tmp.join("build.tar"); + std::fs::write(&file, "artifact bytes").unwrap(); + let digest = state.compute_artifact_cid(file).unwrap(); + state + .register_artifact(rid, id.clone(), digest.cid.clone(), "build.tar".into(), 14) + .unwrap(); + + (state, rid, id, digest.cid) + } + + fn locations(state: &AppState, rid: RepoId, id: &str, cid: &str) -> Vec { + let repo = state.profile.storage.repository(rid).unwrap(); + let id = radicle_artifact::ReleaseId::from_str(id).unwrap(); + let cid = super::parse_cid(cid).unwrap(); + let release = ArtifactStore::open(&repo) + .unwrap() + .get(&id) + .unwrap() + .unwrap(); + release + .artifact(&cid) + .unwrap() + .locations() + .values() + .flatten() + .map(|url| url.to_string()) + .collect() + } + + #[test] + fn add_and_remove_location() { + let tmp = tempfile::tempdir().unwrap(); + let (state, rid, id, cid) = release_with_artifact(tmp.path()); + let url = "https://example.com/build.tar"; + + state + .add_location(rid, id.clone(), cid.clone(), url.into()) + .unwrap(); + assert_eq!(locations(&state, rid, &id, &cid), [url]); + + state + .remove_location(rid, id.clone(), cid.clone(), url.into()) + .unwrap(); + assert!(locations(&state, rid, &id, &cid).is_empty()); + } + + #[test] + fn locations_are_listed_per_contributor() { + let tmp = tempfile::tempdir().unwrap(); + let (state, rid, id, cid) = release_with_artifact(tmp.path()); + let url = "https://example.com/build.tar"; + state + .add_location(rid, id.clone(), cid.clone(), url.into()) + .unwrap(); + + let other = SigningKey::from_seed(Seed::new([0xee; 32])); + { + let repo = state.profile.storage.repository(rid).unwrap(); + let mut releases = ArtifactStore::open(&repo).unwrap(); + let mut release = releases + .get_mut(&radicle_artifact::ReleaseId::from_str(&id).unwrap()) + .unwrap(); + for url in [url, "https://mirror.example.com/build.tar"] { + release + .add_location( + super::parse_cid(&cid).unwrap(), + url::Url::parse(url).unwrap(), + &other, + ) + .unwrap(); + } + } + + let mut locations = read_release(&state, rid, &id).unwrap().artifacts[0] + .locations + .iter() + .map(|l| (*l.user.did(), l.url.clone())) + .collect::>(); + locations.sort(); + let me = radicle::identity::Did::from(state.profile.public_key); + let them = radicle::identity::Did::from(*other.public_key()); + let mut expected = vec![ + (me, url.to_string()), + (them, url.to_string()), + (them, "https://mirror.example.com/build.tar".to_string()), + ]; + expected.sort(); + assert_eq!(locations, expected); + } + + #[test] + fn artifacts_say_whether_they_are_folders() { + let tmp = tempfile::tempdir().unwrap(); + let (state, rid, id, file_cid) = release_with_artifact(tmp.path()); + let folder = tmp.path().join("dist"); + std::fs::create_dir(&folder).unwrap(); + std::fs::write(folder.join("app"), "folder bytes").unwrap(); + let digest = state.compute_artifact_cid(folder).unwrap(); + state + .register_artifact(rid, id.clone(), digest.cid.clone(), "dist".into(), 12) + .unwrap(); + + let release = read_release(&state, rid, &id).unwrap(); + let directory = |cid: &str| { + release + .artifacts + .iter() + .find(|a| a.cid == cid) + .unwrap() + .directory + }; + assert!(!directory(&file_cid)); + assert!(directory(&digest.cid)); + } + + #[test] + fn add_location_rejects_unfetchable_urls() { + let tmp = tempfile::tempdir().unwrap(); + let (state, rid, id, cid) = release_with_artifact(tmp.path()); + + for url in ["not a url", "iroh://abc", "radiroh://not-base32!"] { + let err = state + .add_location(rid, id.clone(), cid.clone(), url.into()) + .unwrap_err(); + assert_eq!(err.code(), "ArtifactError.InvalidLocation", "{url}"); + } + assert!(locations(&state, rid, &id, &cid).is_empty()); + } + + #[test] + fn delete_release_removes_own_release() { + let tmp = tempfile::tempdir().unwrap(); + let profile = test::profile(&tmp.path().join("home"), [0xff; 32]); + let signer = SigningKey::from_seed(Seed::new([0xff; 32])); + let (rid, _, _, head) = + fixtures::project(tmp.path().join("project"), &profile.storage, &signer).unwrap(); + let oid = radicle::git::Oid::from(head); + + let state = AppState { profile }; + let id = state.create_or_open_release(rid, oid, None).unwrap(); + state.delete_release(rid, id.clone()).unwrap(); + + let repo = state.profile.storage.repository(rid).unwrap(); + let id = radicle_artifact::ReleaseId::from_str(&id).unwrap(); + assert!( + ArtifactStore::open(&repo) + .unwrap() + .get(&id) + .unwrap() + .is_none() + ); + } + + /// The release as the app reads it. + pub(crate) fn read_release(state: &AppState, rid: RepoId, id: &str) -> Option { + state + .release_by_id(rid, radicle::git::Oid::from_str(id).unwrap()) + .unwrap() + } + + /// Register `bytes` under the release as a peer who is not a delegate. + fn register_as_other( + state: &AppState, + tmp: &std::path::Path, + rid: RepoId, + id: &str, + bytes: &str, + ) -> (std::path::PathBuf, String) { + let file = tmp.join("theirs.tar"); + std::fs::write(&file, bytes).unwrap(); + let cid = state.compute_artifact_cid(file.clone()).unwrap().cid; + + let other = SigningKey::from_seed(Seed::new([0xee; 32])); + let repo = state.profile.storage.repository(rid).unwrap(); + let mut releases = ArtifactStore::open(&repo).unwrap(); + let mut release = releases + .get_mut(&radicle_artifact::ReleaseId::from_str(id).unwrap()) + .unwrap(); + release + .register_artifact(super::parse_cid(&cid).unwrap(), "theirs.tar".into(), &other) + .unwrap(); + + (file, cid) + } + + #[test] + fn attest_artifact_signs_a_matching_build() { + let tmp = tempfile::tempdir().unwrap(); + let (state, rid, id, _) = release_with_artifact(tmp.path()); + let (file, cid) = register_as_other(&state, tmp.path(), rid, &id, "their bytes"); + + state + .attest_artifact(rid, id.clone(), cid.clone(), file) + .unwrap(); + + let release = read_release(&state, rid, &id).unwrap(); + let artifact = release.artifacts.iter().find(|a| a.cid == cid).unwrap(); + assert_eq!(artifact.attestations.len(), 1); + } + + #[test] + fn attest_artifact_rejects_a_different_build() { + let tmp = tempfile::tempdir().unwrap(); + let (state, rid, id, _) = release_with_artifact(tmp.path()); + let (_, cid) = register_as_other(&state, tmp.path(), rid, &id, "their bytes"); + let build = tmp.path().join("mine.tar"); + std::fs::write(&build, "other bytes").unwrap(); + + let err = state + .attest_artifact(rid, id.clone(), cid.clone(), build) + .unwrap_err(); + + assert_eq!(err.code(), "ArtifactError.CidMismatch"); + let release = read_release(&state, rid, &id).unwrap(); + let artifact = release.artifacts.iter().find(|a| a.cid == cid).unwrap(); + assert!(artifact.attestations.is_empty()); + } + + #[test] + fn set_and_remove_artifact_metadata() { + let tmp = tempfile::tempdir().unwrap(); + let (state, rid, id, cid) = release_with_artifact(tmp.path()); + let metadata = |state: &AppState| { + read_release(state, rid, &id).unwrap().artifacts[0] + .metadata + .get("arch") + .cloned() + }; + + state + .set_artifact_metadata(rid, id.clone(), cid.clone(), "arch".into(), "x86_64".into()) + .unwrap(); + assert_eq!(metadata(&state), Some("x86_64".into())); + + state + .remove_artifact_metadata(rid, id.clone(), cid.clone(), "arch".into()) + .unwrap(); + assert_eq!(metadata(&state), None); + } + + #[test] + fn metadata_from_a_non_author_is_ignored() { + let tmp = tempfile::tempdir().unwrap(); + let (state, rid, id, cid) = release_with_artifact(tmp.path()); + + let other = SigningKey::from_seed(Seed::new([0xee; 32])); + { + let repo = state.profile.storage.repository(rid).unwrap(); + let mut releases = ArtifactStore::open(&repo).unwrap(); + let mut release = releases + .get_mut(&radicle_artifact::ReleaseId::from_str(&id).unwrap()) + .unwrap(); + release + .set_metadata( + super::parse_cid(&cid).unwrap(), + "arch".into(), + "evil".into(), + &other, + ) + .unwrap(); + } + + let release = read_release(&state, rid, &id).unwrap(); + assert_eq!(release.artifacts[0].metadata.get("arch"), None); + } + + #[test] + fn redact_artifact_hides_it_and_keeps_the_reason() { + let tmp = tempfile::tempdir().unwrap(); + let (state, rid, id, cid) = release_with_artifact(tmp.path()); + + state + .redact_artifact(rid, id.clone(), cid, "bad build".into()) + .unwrap(); + + let artifact = &read_release(&state, rid, &id).unwrap().artifacts[0]; + assert!(artifact.redacted); + assert_eq!(artifact.redactions[0].reason, "bad build"); + } + + #[test] + fn redaction_by_a_non_delegate_does_not_hide() { + let tmp = tempfile::tempdir().unwrap(); + let (state, rid, id, cid) = release_with_artifact(tmp.path()); + + let other = SigningKey::from_seed(Seed::new([0xee; 32])); + { + let repo = state.profile.storage.repository(rid).unwrap(); + let mut releases = ArtifactStore::open(&repo).unwrap(); + let mut release = releases + .get_mut(&radicle_artifact::ReleaseId::from_str(&id).unwrap()) + .unwrap(); + release + .redact(super::parse_cid(&cid).unwrap(), "spite".into(), &other) + .unwrap(); + } + + let artifact = &read_release(&state, rid, &id).unwrap().artifacts[0]; + assert!(!artifact.redacted); + assert_eq!(artifact.redactions.len(), 1); + } +} diff --git a/crates/radicle-types/src/traits/repo.rs b/crates/radicle-types/src/traits/repo.rs index 11cfdc894..61c9c13d0 100644 --- a/crates/radicle-types/src/traits/repo.rs +++ b/crates/radicle-types/src/traits/repo.rs @@ -297,6 +297,7 @@ fn resolve_tag(repo: &storage::git::Repository, oid: git::raw::Oid) -> Option Option radicle::Profile { self.profile.deref().clone() @@ -138,6 +142,30 @@ pub fn router(shared: Shared) -> Router { .route("/edit_patch", post(edit_patch_handler)) .route("/create_patch_review", post(create_patch_review_handler)) .route("/delete_patch", post(delete_patch_handler)) + .route( + "/create_or_open_release", + post(create_or_open_release_handler), + ) + .route("/register_artifact", post(register_artifact_handler)) + .route("/attest_artifact", post(attest_artifact_handler)) + .route( + "/set_artifact_metadata", + post(set_artifact_metadata_handler), + ) + .route( + "/remove_artifact_metadata", + post(remove_artifact_metadata_handler), + ) + .route( + "/add_artifact_location", + post(add_artifact_location_handler), + ) + .route( + "/remove_artifact_location", + post(remove_artifact_location_handler), + ) + .route("/redact_artifact", post(redact_artifact_handler)) + .route("/delete_release", post(delete_release_handler)) .route_layer(middleware::from_fn_with_state( Arc::new(Mutex::new(())), serialize_writes, @@ -197,6 +225,10 @@ pub fn router(shared: Shared) -> Router { .route("/revisions_by_patch", post(revision_handler)) .route("/get_embed", post(get_embeds_handler)) .route("/save_embed_to_disk", post(save_embed_to_disk_handler)) + .route("/list_releases", post(releases_handler)) + .route("/release_by_id", post(release_handler)) + .route("/release_counts", post(release_counts_handler)) + .route("/compute_artifact_cid", post(compute_artifact_cid_handler)) .route("/list_jobs", post(jobs_handler)) .route("/list_notifications", post(list_notifications_handler)) .route("/notification_count", post(notification_count_handler)) @@ -983,6 +1015,264 @@ async fn issue_threads_handler( Ok::<_, Error>(Json(issue_threads)) } +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ReleasesBody { + pub rid: identity::RepoId, + pub filter: Option, + pub skip: Option, + pub take: Option, +} + +async fn releases_handler( + Ctx(ctx): Ctx, + Json(ReleasesBody { + rid, + filter, + skip, + take, + }): Json, +) -> impl IntoResponse { + let releases = ctx.list_releases(rid, filter, skip, take)?; + + Ok::<_, Error>(Json(releases)) +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ReleaseBody { + pub rid: identity::RepoId, + pub id: git::Oid, +} + +async fn release_handler( + Ctx(ctx): Ctx, + Json(ReleaseBody { rid, id }): Json, +) -> impl IntoResponse { + let release = ctx.release_by_id(rid, id)?; + + Ok::<_, Error>(Json(release)) +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ReleaseCountsBody { + pub rid: identity::RepoId, +} + +async fn release_counts_handler( + Ctx(ctx): Ctx, + Json(ReleaseCountsBody { rid }): Json, +) -> impl IntoResponse { + let counts = ctx.release_counts(rid)?; + + Ok::<_, Error>(Json(counts)) +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ComputeArtifactCidBody { + pub path: std::path::PathBuf, +} + +async fn compute_artifact_cid_handler( + Ctx(ctx): Ctx, + Json(ComputeArtifactCidBody { path }): Json, +) -> impl IntoResponse { + let digest = ctx.compute_artifact_cid(path)?; + + Ok::<_, Error>(Json(digest)) +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CreateOrOpenReleaseBody { + pub rid: identity::RepoId, + pub oid: git::Oid, + pub tag: Option, +} + +async fn create_or_open_release_handler( + Ctx(ctx): Ctx, + Json(CreateOrOpenReleaseBody { rid, oid, tag }): Json, +) -> impl IntoResponse { + let id = ctx.create_or_open_release(rid, oid, tag)?; + + Ok::<_, Error>(Json(id)) +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RegisterArtifactBody { + pub rid: identity::RepoId, + pub release_id: String, + pub cid: String, + pub name: String, + pub size_bytes: u64, +} + +async fn register_artifact_handler( + Ctx(ctx): Ctx, + Json(RegisterArtifactBody { + rid, + release_id, + cid, + name, + size_bytes, + }): Json, +) -> impl IntoResponse { + ctx.register_artifact(rid, release_id, cid, name, size_bytes)?; + + Ok::<_, Error>(Json(())) +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AttestArtifactBody { + pub rid: identity::RepoId, + pub release_id: String, + pub cid: String, + pub path: std::path::PathBuf, +} + +async fn attest_artifact_handler( + Ctx(ctx): Ctx, + Json(AttestArtifactBody { + rid, + release_id, + cid, + path, + }): Json, +) -> impl IntoResponse { + ctx.attest_artifact(rid, release_id, cid, path)?; + + Ok::<_, Error>(Json(())) +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SetArtifactMetadataBody { + pub rid: identity::RepoId, + pub release_id: String, + pub cid: String, + pub key: String, + pub value: serde_json::Value, +} + +async fn set_artifact_metadata_handler( + Ctx(ctx): Ctx, + Json(SetArtifactMetadataBody { + rid, + release_id, + cid, + key, + value, + }): Json, +) -> impl IntoResponse { + ctx.set_artifact_metadata(rid, release_id, cid, key, value)?; + + Ok::<_, Error>(Json(())) +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RemoveArtifactMetadataBody { + pub rid: identity::RepoId, + pub release_id: String, + pub cid: String, + pub key: String, +} + +async fn remove_artifact_metadata_handler( + Ctx(ctx): Ctx, + Json(RemoveArtifactMetadataBody { + rid, + release_id, + cid, + key, + }): Json, +) -> impl IntoResponse { + ctx.remove_artifact_metadata(rid, release_id, cid, key)?; + + Ok::<_, Error>(Json(())) +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ArtifactLocationBody { + pub rid: identity::RepoId, + pub release_id: String, + pub cid: String, + pub url: String, +} + +async fn add_artifact_location_handler( + Ctx(ctx): Ctx, + Json(ArtifactLocationBody { + rid, + release_id, + cid, + url, + }): Json, +) -> impl IntoResponse { + ctx.add_location(rid, release_id, cid, url)?; + + Ok::<_, Error>(Json(())) +} + +async fn remove_artifact_location_handler( + Ctx(ctx): Ctx, + Json(ArtifactLocationBody { + rid, + release_id, + cid, + url, + }): Json, +) -> impl IntoResponse { + ctx.remove_location(rid, release_id, cid, url)?; + + Ok::<_, Error>(Json(())) +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RedactArtifactBody { + pub rid: identity::RepoId, + pub release_id: String, + pub cid: String, + pub reason: String, +} + +async fn redact_artifact_handler( + Ctx(ctx): Ctx, + Json(RedactArtifactBody { + rid, + release_id, + cid, + reason, + }): Json, +) -> impl IntoResponse { + ctx.redact_artifact(rid, release_id, cid, reason)?; + + Ok::<_, Error>(Json(())) +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DeleteReleaseBody { + pub rid: identity::RepoId, + pub release_id: String, +} + +async fn delete_release_handler( + Ctx(ctx): Ctx, + Json(DeleteReleaseBody { rid, release_id }): Json, +) -> impl IntoResponse { + ctx.delete_release(rid, release_id)?; + + Ok::<_, Error>(Json(())) +} + #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct PatchesBody { diff --git a/crates/test-http-api/tests/parity.rs b/crates/test-http-api/tests/parity.rs index 2cdb6a397..b535626b9 100644 --- a/crates/test-http-api/tests/parity.rs +++ b/crates/test-http-api/tests/parity.rs @@ -6,7 +6,16 @@ use std::fs; use std::path::{Path, PathBuf}; /// Tauri commands with no HTTP route, and why. -const TAURI_ONLY: &[(&str, &str)] = &[]; +const TAURI_ONLY: &[(&str, &str)] = &[ + ( + "pick_artifact_files", + "opens a native file dialog, which the HTTP server has no access to", + ), + ( + "pick_artifact_directory", + "opens a native file dialog, which the HTTP server has no access to", + ), +]; /// HTTP routes with no Tauri command, and why. const HTTP_ONLY: &[(&str, &str)] = &[( diff --git a/docs/adr/0002-backend-architecture.md b/docs/adr/0002-backend-architecture.md index 628752d03..b31959bb6 100644 --- a/docs/adr/0002-backend-architecture.md +++ b/docs/adr/0002-backend-architecture.md @@ -47,7 +47,9 @@ and a generic `Service` over it. The `Sqlite` adapter in `src/outbound/` implements those ports by querying Radicle's COB cache and notifications database directly (read-only). Both drivers construct the services at startup and use them for the matching list endpoints; everything else -keeps going through the ports. +keeps going through the ports. Releases are the exception: `radicle-artifact` +keeps its own SQLite cache of release COBs, so `list_releases` reads through +that instead of a domain service. **Parity is enforced.** Every Tauri command has a matching HTTP route. `cargo test -p test-http-api --test parity` fails when a command, route or