diff --git a/Cargo.lock b/Cargo.lock index cfa8c163..900c97c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -30,27 +30,15 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.65" +version = "1.0.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98161a4e3e2184da77bb14f02184cdd111e83bbbcc9979dfee3c44b9a85f5602" - -[[package]] -name = "arrayref" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4c527152e37cf757a3f78aae5a06fbeefdb07ccc535c980a3208ee3060dd544" - -[[package]] -name = "arrayvec" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" +checksum = "216261ddc8289130e551ddcd5ce8a064710c0d064a4d2895c67151c92b5443f6" [[package]] name = "async-trait" -version = "0.1.57" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76464446b8bc32758d7e88ee1a804d9914cd9b1cb264c029899680b0be29826f" +checksum = "1e805d94e6b5001b651426cf4cd446b1ab5f319d27bab5c644f61de0a804360c" dependencies = [ "proc-macro2", "quote", @@ -76,9 +64,9 @@ checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" [[package]] name = "axum" -version = "0.5.16" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e3356844c4d6a6d6467b8da2cffb4a2820be256f50a3a386c9d152bab31043" +checksum = "acee9fd5073ab6b045a275b3e709c163dd36c90685219cb21804a147b58dba43" dependencies = [ "async-trait", "axum-core", @@ -105,9 +93,9 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.2.8" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9f0c0a60006f2a293d82d571f635042a72edf927539b7685bd62d361963839b" +checksum = "37e5939e02c56fecd5c017c37df4238c0a839fa76b7f97acdd7efb804fd181cc" dependencies = [ "async-trait", "bytes", @@ -148,9 +136,9 @@ checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" [[package]] name = "base64" -version = "0.13.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" [[package]] name = "base64ct" @@ -175,35 +163,12 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" -[[package]] -name = "blake2b_simd" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afa748e348ad3be8263be728124b24a24f268266f6f5d58af9d75f6a40b5c587" -dependencies = [ - "arrayref", - "arrayvec", - "constant_time_eq", -] - -[[package]] -name = "blake2s_simd" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e461a7034e85b211a4acb57ee2e6730b32912b06c08cc242243c39fc21ae6a2" -dependencies = [ - "arrayref", - "arrayvec", - "constant_time_eq", -] - [[package]] name = "block-buffer" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" dependencies = [ - "block-padding", "generic-array", ] @@ -216,12 +181,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block-padding" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d696c370c750c948ada61c69a0ee2cbbb9c50b1019ddb86d9317157a99c2cae" - [[package]] name = "bloomy" version = "1.2.0" @@ -243,9 +202,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.11.0" +version = "3.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1ad822118d20d2c234f427000d5acc36eabe1e29a348c89b63dd60b13f28e5d" +checksum = "572f695136211188308f16ad2ca5c851a712c464060ae6974944458eb83880ba" [[package]] name = "byteorder" @@ -261,9 +220,9 @@ checksum = "ec8a7b6a70fde80372154c65702f00a0f56f3e1c36abbc6c440484be248856db" [[package]] name = "cc" -version = "1.0.73" +version = "1.0.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fff2a6927b3bb87f9595d67196a70493f627687a71d87a0d692242c33f58c11" +checksum = "581f5dba903aac52ea3feb5ec4810848460ee833876f1f9b0fdeab1f19091574" dependencies = [ "jobserver", ] @@ -299,6 +258,16 @@ dependencies = [ "inout", ] +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + [[package]] name = "colored" version = "1.9.3" @@ -316,12 +285,6 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "722e23542a15cea1f65d4a1419c4cfd7a26706c70871a13a04238ca3f40f1661" -[[package]] -name = "constant_time_eq" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" - [[package]] name = "core-foundation-sys" version = "0.8.3" @@ -415,6 +378,50 @@ dependencies = [ "zeroize", ] +[[package]] +name = "cxx" +version = "1.0.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7d4e43b25d3c994662706a1d4fcfc32aaa6afd287502c111b237093bb23f3a" +dependencies = [ + "cc", + "cxxbridge-flags", + "cxxbridge-macro", + "link-cplusplus", +] + +[[package]] +name = "cxx-build" +version = "1.0.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84f8829ddc213e2c1368e51a2564c552b65a8cb6a28f31e576270ac81d5e5827" +dependencies = [ + "cc", + "codespan-reporting", + "once_cell", + "proc-macro2", + "quote", + "scratch", + "syn", +] + +[[package]] +name = "cxxbridge-flags" +version = "1.0.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e72537424b474af1460806647c41d4b6d35d09ef7fe031c5c2fa5766047cc56a" + +[[package]] +name = "cxxbridge-macro" +version = "1.0.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "309e4fb93eed90e1e14bea0da16b209f81813ba9fc7830c20ed151dd7bc0a4d7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "data-encoding" version = "2.3.2" @@ -496,12 +503,12 @@ dependencies = [ [[package]] name = "ed25519-compact" -version = "1.0.15" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee9df587982575886a8682edcee11877894349a805f25629c27f63abe3e9ae8" +checksum = "e18997d4604542d0736fae2c5ad6de987f0a50530cbcc14a7ce5a685328a252d" dependencies = [ "ct-codecs", - "getrandom 0.2.7", + "getrandom 0.2.8", ] [[package]] @@ -556,6 +563,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "fixedbitset" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37ab347416e802de484e4d03c7316c48f1ecb56574dfd4a46a80f173ce1de04d" + [[package]] name = "flate2" version = "1.0.24" @@ -583,36 +596,36 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.24" +version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30bdd20c28fadd505d0fd6712cdfcb0d4b5648baf45faef7f852afb2399bb050" +checksum = "52ba265a92256105f45b719605a571ffe2d1f0fea3807304b522c1d778f79eed" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.24" +version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e5aa3de05362c3fb88de6531e6296e85cde7739cccad4b9dfeeb7f6ebce56bf" +checksum = "04909a7a7e4633ae6c4a9ab280aeb86da1236243a77b694a49eacd659a4bd3ac" [[package]] name = "futures-sink" -version = "0.3.24" +version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b20ba5a92e727ba30e72834706623d94ac93a725410b6a6b6fbc1b07f7ba56" +checksum = "39c15cf1a4aa79df40f1bb462fb39676d0ad9e366c2a33b590d7c66f4f81fcf9" [[package]] name = "futures-task" -version = "0.3.24" +version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6508c467c73851293f390476d4491cf4d227dbabcd4170f3bb6044959b294f1" +checksum = "2ffb393ac5d9a6eaa9d3fdf37ae2776656b706e200c8e16b1bdb227f5198e6ea" [[package]] name = "futures-util" -version = "0.3.24" +version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44fb6cb1be61cc1d2e43b262516aafcf63b241cffdb1d3fa115f91d9c7b09c90" +checksum = "197676987abd2f9cadff84926f410af1c183608d36641465df73ae8211dc65d6" dependencies = [ "futures-core", "futures-task", @@ -643,15 +656,26 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.7" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4eb1a864a501629691edf6c15a593b7a51eebaa1e8468e9ddc623de7c9b58ec6" +checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31" dependencies = [ "cfg-if", "libc", "wasi 0.11.0+wasi-snapshot-preview1", ] +[[package]] +name = "git-commit" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e5120c3ada84aa4bc54602ab0c02c223a5a569b5cf28ae59b2ac423d5b3f0e9" +dependencies = [ + "git-trailers", + "git2", + "thiserror", +] + [[package]] name = "git-ref-format" version = "0.1.0" @@ -684,6 +708,16 @@ dependencies = [ "syn", ] +[[package]] +name = "git-trailers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0934f57135449b88bea0e28efd80aab0c1b53692f8207c7e232086db824c7a8" +dependencies = [ + "nom", + "thiserror", +] + [[package]] name = "git2" version = "0.15.0" @@ -710,9 +744,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.3.14" +version = "0.3.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca32592cf21ac7ccab1825cd87f6c9b3d9022c44d086172ed0966bec8af30be" +checksum = "5f9f29bc9dda355256b2916cf526ab02ce0aeaaaf2bad60d65ef3f12f11dd0f4" dependencies = [ "bytes", "fnv", @@ -793,9 +827,9 @@ checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" [[package]] name = "hyper" -version = "0.14.20" +version = "0.14.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02c929dc5c39e335a03c405292728118860721b10190d98c2a0f0efd5baafbac" +checksum = "abfba89e19b959ca163c7752ba59d737c1ceea53a5d31a149c805446fc958064" dependencies = [ "bytes", "futures-channel", @@ -817,17 +851,28 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.50" +version = "0.1.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd911b35d940d2bd0bea0f9100068e5b97b51a1cbe13d13382f132e0365257a0" +checksum = "64c122667b287044802d6ce17ee2ddf13207ed924c712de9a66a5814d5b64765" dependencies = [ "android_system_properties", "core-foundation-sys", + "iana-time-zone-haiku", "js-sys", "wasm-bindgen", "winapi", ] +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0703ae284fc167426161c2e3f1da3ea71d94b21bedbcc9494e92b28e334e3dca" +dependencies = [ + "cxx", + "cxx-build", +] + [[package]] name = "idna" version = "0.3.0" @@ -868,9 +913,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8af84674fe1f223a982c933a0ee1086ac4d4052aa0fb8060c12c6ad838e754" +checksum = "4217ad341ebadf8d8e724e264f13e593e0648f5b3e94b3896a5df283be015ecc" [[package]] name = "jobserver" @@ -890,12 +935,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "keccak" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9b7d56ba4a8344d6be9729995e6b06f928af29998cdf79fe390cbf6b1fee838" - [[package]] name = "lazy_static" version = "1.4.0" @@ -913,9 +952,9 @@ checksum = "478ee9e62aaeaf5b140bd4138753d1f109765488581444218d3ddda43234f3e8" [[package]] name = "libc" -version = "0.2.134" +version = "0.2.137" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "329c933548736bc49fd575ee68c89e8be4d260064184389a5b77517cddd99ffb" +checksum = "fc7fcc620a3bff7cdd7a365be3376c97191aeaccc2a603e600951e452615bf89" [[package]] name = "libgit2-sys" @@ -947,6 +986,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "link-cplusplus" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9272ab7b96c9046fbc5bc56c06c117cb639fe2d509df0c421cad82d2915cf369" +dependencies = [ + "cc", +] + [[package]] name = "log" version = "0.4.17" @@ -983,6 +1031,12 @@ version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.5.4" @@ -994,9 +1048,9 @@ dependencies = [ [[package]] name = "mio" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57ee1c23c7c63b0c9250c339ffdc69255f110b298b901b9f6c82547b7b87caaf" +checksum = "e5d732bc30207a6423068df043e3d02e0735b155ad7ce1a6f76fe2baa5b158de" dependencies = [ "libc", "log", @@ -1015,21 +1069,6 @@ dependencies = [ "data-encoding-macro", ] -[[package]] -name = "multihash" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567122ab6492f49b59def14ecc36e13e64dca4188196dd0cd41f9f3f979f3df6" -dependencies = [ - "blake2b_simd", - "blake2s_simd", - "digest 0.9.0", - "sha-1", - "sha2 0.9.9", - "sha3", - "unsigned-varint", -] - [[package]] name = "nakamoto-net" version = "0.3.0" @@ -1054,6 +1093,16 @@ dependencies = [ "socket2", ] +[[package]] +name = "nom" +version = "7.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8903e5a29a317527874d0402f867152a3d21c908bb0b933e416c65e301d4c36" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nonempty" version = "0.8.0" @@ -1153,9 +1202,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e82dad04139b71a90c080c8463fe0dc7902db5192d939bd0950f074d014339e1" +checksum = "86f0b0d4bf799edbc74508c1e8bf170ff5f41238e5f8225603ca7caaae2b7860" [[package]] name = "opaque-debug" @@ -1215,6 +1264,16 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e" +[[package]] +name = "petgraph" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "467d164a6de56270bd7c4d070df81d07beace25012d5103ced4e9ff08d6afdb7" +dependencies = [ + "fixedbitset", + "indexmap", +] + [[package]] name = "pin-project" version = "1.0.12" @@ -1271,9 +1330,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.25" +version = "0.3.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae" +checksum = "6ac9a59f73473f1b8d852421e59e64809f025994837ef743615c6d0c5b305160" [[package]] name = "popol" @@ -1316,9 +1375,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94e2ef8dbfc347b10c094890f778ee2e36ca9bb4262e86dc99cd217e35f3470b" +checksum = "5ea3d908b0e36316caf9e9e2c4625cdde190a7e6f440d794667ed17a1855e725" dependencies = [ "unicode-ident", ] @@ -1383,6 +1442,26 @@ dependencies = [ "zeroize", ] +[[package]] +name = "radicle-cob" +version = "0.1.0" +dependencies = [ + "ed25519-compact", + "git-commit", + "git-ref-format", + "git-trailers", + "git2", + "log", + "petgraph", + "quickcheck", + "radicle-crypto", + "radicle-git-ext", + "serde", + "serde_json", + "tempfile", + "thiserror", +] + [[package]] name = "radicle-crypto" version = "0.1.0" @@ -1406,13 +1485,12 @@ dependencies = [ [[package]] name = "radicle-git-ext" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0906d0de75de1e19d595b9fa2a8a1478a8d75e057399e9795ffbe9b6864be4c" +checksum = "25ed92fcf331d19b3110bbed8d3fe2bd99dc75f0059fd135727d24ef829de507" dependencies = [ "git-ref-format", "git2", - "multihash", "percent-encoding", "radicle-std-ext", "serde", @@ -1560,7 +1638,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.7", + "getrandom 0.2.8", ] [[package]] @@ -1661,6 +1739,12 @@ dependencies = [ "cipher", ] +[[package]] +name = "scratch" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8132065adcfd6e02db789d9285a0deb2f3fcb04002865ab67d5fb103533898" + [[package]] name = "scrypt" version = "0.10.0" @@ -1689,18 +1773,18 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.145" +version = "1.0.147" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728eb6351430bccb993660dfffc5a72f91ccc1295abaa8ce19b27ebe4f75568b" +checksum = "d193d69bae983fc11a79df82342761dfbf28a99fc8d203dca4c3c1b590948965" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.145" +version = "1.0.147" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81fa1584d3d1bcacd84c277a0dfe21f5b0f6accf4a23d04d4c6d61f1af522b4c" +checksum = "4f1d362ca8fc9c3e3a7484440752472d68a6caa98f1ab81d99b5dfe517cec852" dependencies = [ "proc-macro2", "quote", @@ -1709,9 +1793,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.85" +version = "1.0.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e55a28e3aaef9d5ce0506d0a14dbba8054ddc7e499ef522dd8b26859ec9d4a44" +checksum = "6ce777b7b150d76b9cf60d28b55f5847135a003f7d7350c6be7a773508ce7d45" dependencies = [ "indexmap", "itoa", @@ -1719,19 +1803,6 @@ dependencies = [ "serde", ] -[[package]] -name = "sha-1" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99cd6713db3cf16b6c84e06321e049a9b9f699826e16096d23bbcc44d15d51a6" -dependencies = [ - "block-buffer 0.9.0", - "cfg-if", - "cpufeatures", - "digest 0.9.0", - "opaque-debug", -] - [[package]] name = "sha2" version = "0.9.9" @@ -1756,18 +1827,6 @@ dependencies = [ "digest 0.10.5", ] -[[package]] -name = "sha3" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f81199417d4e5de3f04b1e871023acea7389672c4135918f05aa9cbf2f2fa809" -dependencies = [ - "block-buffer 0.9.0", - "digest 0.9.0", - "keccak", - "opaque-debug", -] - [[package]] name = "sharded-slab" version = "0.1.4" @@ -1836,9 +1895,9 @@ dependencies = [ [[package]] name = "sqlite" -version = "0.27.0" +version = "0.27.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2df8edd55685048550daaaf2be9024182f3523086cc86f7d50c136e55173e8c" +checksum = "e66cb949f931ece6201d72bffad3f3601b94998a345793713dd13af70a77c185" dependencies = [ "libc", "sqlite3-sys", @@ -1904,9 +1963,9 @@ checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" [[package]] name = "syn" -version = "1.0.101" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e90cde112c4b9690b8cbe810cba9ddd8bc1d7472e2cae317b69e9438c1cba7d2" +checksum = "a864042229133ada95abf3b54fdc62ef5ccabe9515b64717bcb9a1919e59445d" dependencies = [ "proc-macro2", "quote", @@ -1945,6 +2004,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "termcolor" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.37" @@ -1987,13 +2055,31 @@ dependencies = [ [[package]] name = "time" -version = "0.3.15" +version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d634a985c4d4238ec39cacaed2e7ae552fbd3c476b552c1deac3021b7d7eaf0c" +checksum = "0fab5c8b9980850e06d92ddbe3ab839c062c801f3927c0fb8abd6fc8e918fbca" dependencies = [ "itoa", "libc", "num_threads", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e153e1f1acaef8acc537e68b44906d2db6436e2b35ac2c6b42640fff91f00fd" + +[[package]] +name = "time-macros" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bb801831d812c562ae7d2bfb531f26e66e4e1f6b17307ba4149c5064710e5b" +dependencies = [ + "time-core", ] [[package]] @@ -2153,7 +2239,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08aacc136419ba433b3f9bfd434a1bb62fe385328935e6ac11d952122b8a8cb" dependencies = [ - "time 0.3.15", + "time 0.3.16", "tracing", "tracing-core", "tracing-subscriber", @@ -2197,9 +2283,9 @@ checksum = "099b7128301d285f79ddd55b9a83d5e6b9e97c92e0ea0daebee7263e932de992" [[package]] name = "unicode-ident" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcc811dc4066ac62f84f11307873c4850cb653bfa9b1719cee2bd2204a4bc5dd" +checksum = "6ceab39d59e4c9499d4e5a8ee0e2735b891bb7308ac83dfb4e80cad195c9f6f3" [[package]] name = "unicode-normalization" @@ -2210,18 +2296,18 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-width" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" + [[package]] name = "unicode-xid" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" -[[package]] -name = "unsigned-varint" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7fdeedbf205afadfe39ae559b75c3240f24e257d0ca27e85f85cb82aa19ac35" - [[package]] name = "url" version = "2.3.1" @@ -2349,6 +2435,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -2357,46 +2452,60 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows-sys" -version = "0.36.1" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea04155a16a59f9eab786fe12a4a450e75cdb175f9e0d80da1e17db09f55b8d2" +checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" dependencies = [ + "windows_aarch64_gnullvm", "windows_aarch64_msvc", "windows_i686_gnu", "windows_i686_msvc", "windows_x86_64_gnu", + "windows_x86_64_gnullvm", "windows_x86_64_msvc", ] [[package]] -name = "windows_aarch64_msvc" -version = "0.36.1" +name = "windows_aarch64_gnullvm" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47" +checksum = "41d2aa71f6f0cbe00ae5167d90ef3cfe66527d6f613ca78ac8024c3ccab9a19e" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0f252f5a35cac83d6311b2e795981f5ee6e67eb1f9a7f64eb4500fbc4dcdb4" [[package]] name = "windows_i686_gnu" -version = "0.36.1" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6" +checksum = "fbeae19f6716841636c28d695375df17562ca208b2b7d0dc47635a50ae6c5de7" [[package]] name = "windows_i686_msvc" -version = "0.36.1" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024" +checksum = "84c12f65daa39dd2babe6e442988fc329d6243fdce47d7d2d155b8d874862246" [[package]] name = "windows_x86_64_gnu" -version = "0.36.1" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1" +checksum = "bf7b1b21b5362cbc318f686150e5bcea75ecedc74dd157d874d754a2ca44b0ed" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09d525d2ba30eeb3297665bd434a54297e4170c7f1a44cad4ef58095b4cd2028" [[package]] name = "windows_x86_64_msvc" -version = "0.36.1" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680" +checksum = "f40009d85759725a34da6d89a94e63d7bdc50a862acf0dbc7c8e488f1edcb6f5" [[package]] name = "zeroize" diff --git a/Cargo.toml b/Cargo.toml index 0c28f659..4615bfc5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,13 @@ [workspace] members = [ "radicle", + "radicle-cob", "radicle-crypto", + "radicle-httpd", "radicle-node", - "radicle-tools", - "radicle-ssh", "radicle-remote-helper", - "radicle-httpd" + "radicle-ssh", + "radicle-tools", ] default-members = [ "radicle", diff --git a/radicle-cob/Cargo.toml b/radicle-cob/Cargo.toml new file mode 100644 index 00000000..399cfc96 --- /dev/null +++ b/radicle-cob/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "radicle-cob" +version = "0.1.0" +authors = [ + "Alex Good ", + "Fintan Halpenny ", +] +edition = "2021" +license = "GPL-3.0-or-later" +description = "Library for implementing Radicle Collaborative Objects" +keywords = ["radicle", "collaborative objects", "cob", "cobs"] + +[lib] + +[dependencies] +git-commit = { version = "0.2" } +git-ref-format = { version = "0.1" } +git-trailers = { version = "0.1" } +log = { version = "0.4.17" } +petgraph = { version = "0.5" } +radicle-git-ext = { version = "0.2" } +serde_json = { version = "1.0" } +thiserror = { version = "1.0" } + +[dependencies.git2] +version = "0.15.0" +default-features = false +features = ["vendored-libgit2"] + +[dependencies.radicle-crypto] +path = "../radicle-crypto" +version = "0.1" +features = ["ssh"] + +[dependencies.serde] +version = "1.0" +features = ["derive"] + +[dev-dependencies] +ed25519-compact = { version = "1.0.12", features = ["pem"] } +tempfile = { version = "3" } +quickcheck = { version = "1", default-features = false } + +[dev-dependencies.radicle-crypto] +path = "../radicle-crypto" +version = "0.1" +features = ["test"] diff --git a/radicle-cob/src/backend.rs b/radicle-cob/src/backend.rs new file mode 100644 index 00000000..3c5c0ce5 --- /dev/null +++ b/radicle-cob/src/backend.rs @@ -0,0 +1,6 @@ +// Copyright © 2022 The Radicle Link Contributors +// +// This file is part of radicle-link, distributed under the GPLv3 with Radicle +// Linking Exception. For full terms see the included LICENSE file. + +pub mod git; diff --git a/radicle-cob/src/backend/git.rs b/radicle-cob/src/backend/git.rs new file mode 100644 index 00000000..4cd70cb2 --- /dev/null +++ b/radicle-cob/src/backend/git.rs @@ -0,0 +1,6 @@ +// Copyright © 2022 The Radicle Link Contributors +// +// This file is part of radicle-link, distributed under the GPLv3 with Radicle +// Linking Exception. For full terms see the included LICENSE file. + +pub mod change; diff --git a/radicle-cob/src/backend/git/change.rs b/radicle-cob/src/backend/git/change.rs new file mode 100644 index 00000000..980944bd --- /dev/null +++ b/radicle-cob/src/backend/git/change.rs @@ -0,0 +1,284 @@ +// Copyright © 2022 The Radicle Link Contributors +// +// This file is part of radicle-link, distributed under the GPLv3 with Radicle +// Linking Exception. For full terms see the included LICENSE file. + +use std::convert::TryFrom; + +use git_commit::{self as commit, Commit}; +use git_ext::Oid; +use git_trailers::OwnedTrailer; + +use crate::{ + change::{self, store, Change}, + history::entry, + signatures::{Signature, Signatures}, + trailers, HistoryType, +}; + +const MANIFEST_BLOB_NAME: &str = "manifest"; +const CHANGE_BLOB_NAME: &str = "change"; + +pub mod error { + use std::str::Utf8Error; + use std::string::FromUtf8Error; + + use git_ext::Oid; + use git_trailers::Error as TrailerError; + use thiserror::Error; + + use crate::signatures::error::Signatures; + use crate::trailers; + + #[derive(Debug, Error)] + pub enum Create { + #[error(transparent)] + FromUtf8(#[from] FromUtf8Error), + #[error(transparent)] + Git(#[from] git2::Error), + #[error(transparent)] + Signer(#[from] Box), + #[error(transparent)] + Utf8(#[from] Utf8Error), + } + + #[derive(Debug, Error)] + pub enum Load { + #[error(transparent)] + Read(#[from] git_commit::error::Read), + #[error(transparent)] + Signatures(#[from] Signatures), + #[error(transparent)] + Git(#[from] git2::Error), + #[error("a 'manifest' file was expected be found in '{0}'")] + NoManifest(Oid), + #[error("the 'manifest' found at '{0}' was not a blob")] + ManifestIsNotBlob(Oid), + #[error("the 'manifest' found at '{id}' was invalid: {err}")] + InvalidManifest { + id: Oid, + #[source] + err: serde_json::Error, + }, + #[error("a 'change' file was expected be found in '{0}'")] + NoChange(Oid), + #[error("the 'change' found at '{0}' was not a blob")] + ChangeNotBlob(Oid), + #[error(transparent)] + AuthorTrailer(#[from] trailers::error::InvalidAuthorTrailer), + #[error(transparent)] + ResourceTrailer(#[from] super::trailers::error::InvalidResourceTrailer), + #[error("non utf-8 characters in commit message")] + Utf8(#[from] FromUtf8Error), + #[error(transparent)] + Trailer(#[from] TrailerError), + } +} + +impl change::Storage for git2::Repository { + type CreateError = error::Create; + type LoadError = error::Load; + + type ObjectId = Oid; + type Author = Oid; + type Resource = Oid; + type Signatures = Signatures; + + fn create( + &self, + author: Option, + resource: Self::Resource, + signer: &Signer, + spec: store::Create, + ) -> Result + where + Signer: crypto::Signer, + { + let change::Create { + typename, + tips, + message, + contents, + } = spec; + let manifest = store::Manifest { + typename, + history_type: (&contents).into(), + }; + + let revision = write_manifest(self, &manifest, &contents)?; + let tree = self.find_tree(revision)?; + + let signature = { + let sig = signer.sign(revision.as_bytes()); + let key = signer.public_key(); + Signature::from((*key, sig)) + }; + + let id = write_commit( + self, + author, + resource, + tips, + message, + signature.clone(), + tree, + )?; + Ok(Change { + id, + revision: revision.into(), + signatures: signature.into(), + author, + resource, + manifest, + contents, + }) + } + + fn load(&self, id: Self::ObjectId) -> Result { + let commit = Commit::read(self, id.into())?; + let (author, resource) = parse_trailers(commit.trailers())?; + let signatures = Signatures::try_from(&commit)?; + + let tree = self.find_tree(commit.tree())?; + let manifest = load_manifest(self, &tree)?; + let contents = load_contents(self, &tree, &manifest)?; + + Ok(Change { + id, + revision: tree.id().into(), + signatures, + author, + resource, + manifest, + contents, + }) + } +} + +fn parse_trailers<'a>( + mut trailers: impl Iterator, +) -> Result<(Option, Oid), error::Load> { + let (author, resource) = trailers.try_fold((None, None), |(author, resource), trailer| { + match trailers::AuthorCommitTrailer::try_from(trailer) { + Ok(trailer) => Ok((Some(trailer.oid().into()), resource)), + Err(err) => match err { + trailers::error::InvalidAuthorTrailer::NoTrailer + | trailers::error::InvalidAuthorTrailer::NoValue => Ok((author, resource)), + trailers::error::InvalidAuthorTrailer::WrongToken => { + let resource = trailers::ResourceCommitTrailer::try_from(trailer)?; + Ok((author, Some(resource.oid().into()))) + } + err => Err(error::Load::from(err)), + }, + } + })?; + let resource = resource + .ok_or_else(|| error::Load::from(trailers::error::InvalidResourceTrailer::NoTrailer))?; + Ok((author, resource)) +} + +fn load_manifest( + repo: &git2::Repository, + tree: &git2::Tree, +) -> Result { + let manifest_tree_entry = tree + .get_name(MANIFEST_BLOB_NAME) + .ok_or_else(|| error::Load::NoManifest(tree.id().into()))?; + let manifest_object = manifest_tree_entry.to_object(repo)?; + let manifest_blob = manifest_object + .as_blob() + .ok_or_else(|| error::Load::ManifestIsNotBlob(tree.id().into()))?; + serde_json::from_slice(manifest_blob.content()).map_err(|err| error::Load::InvalidManifest { + id: tree.id().into(), + err, + }) +} + +fn load_contents( + repo: &git2::Repository, + tree: &git2::Tree, + manifest: &store::Manifest, +) -> Result { + Ok(match manifest.history_type { + HistoryType::Automerge => { + let contents_tree_entry = tree + .get_name(CHANGE_BLOB_NAME) + .ok_or_else(|| error::Load::NoChange(tree.id().into()))?; + let contents_object = contents_tree_entry.to_object(repo)?; + let contents_blob = contents_object + .as_blob() + .ok_or_else(|| error::Load::ChangeNotBlob(tree.id().into()))?; + entry::Contents::automerge(contents_blob.content()) + } + }) +} + +fn write_commit( + repo: &git2::Repository, + author: Option, + resource: O, + tips: Vec, + message: String, + signature: Signature, + tree: git2::Tree, +) -> Result +where + O: AsRef, +{ + let author = author.map(|author| *author.as_ref()); + let resource = *resource.as_ref(); + + let mut parents = tips.iter().map(|o| *o.as_ref()).collect::>(); + parents.push(resource); + parents.extend(author); + + let mut trailers: Vec = + vec![trailers::ResourceCommitTrailer::from(resource).into()]; + trailers.extend(author.map(|author| trailers::AuthorCommitTrailer::from(author).into())); + + { + let author = repo.signature()?; + let mut headers = commit::Headers::new(); + headers.push( + "gpgsig", + &String::from_utf8(crypto::ssh::ExtendedSignature::from(signature).to_armored())?, + ); + let author = commit::Author::try_from(&author)?; + + let commit = Commit::new( + tree.id(), + parents, + author.clone(), + author, + headers, + message, + trailers, + ); + commit + .write(repo) + .map(Oid::from) + .map_err(error::Create::from) + } +} + +fn write_manifest( + repo: &git2::Repository, + manifest: &store::Manifest, + contents: &entry::Contents, +) -> Result { + let mut tb = repo.treebuilder(None)?; + // SAFETY: we're serializing to an in memory buffer so the only source of + // errors here is a programming error, which we can't recover from + let serialized_manifest = serde_json::to_vec(manifest).unwrap(); + let manifest_oid = repo.blob(&serialized_manifest)?; + tb.insert( + MANIFEST_BLOB_NAME, + manifest_oid, + git2::FileMode::Blob.into(), + )?; + + let change_blob = repo.blob(contents.as_ref())?; + tb.insert(CHANGE_BLOB_NAME, change_blob, git2::FileMode::Blob.into())?; + + tb.write() +} diff --git a/radicle-cob/src/backend/git/objects.rs b/radicle-cob/src/backend/git/objects.rs new file mode 100644 index 00000000..e69de29b diff --git a/radicle-cob/src/change.rs b/radicle-cob/src/change.rs new file mode 100644 index 00000000..212f6198 --- /dev/null +++ b/radicle-cob/src/change.rs @@ -0,0 +1,16 @@ +// Copyright © 2021 The Radicle Link Contributors +// +// This file is part of radicle-link, distributed under the GPLv3 with Radicle +// Linking Exception. For full terms see the included LICENSE file. + +use git_ext::Oid; + +pub mod store; +pub use store::{Create, Storage}; + +use crate::signatures::Signatures; + +/// A single change in the change graph. The layout of changes in the repository +/// is specified in the RFC (docs/rfc/0662-collaborative-objects.adoc) +/// under "Change Commits". +pub type Change = store::Change; diff --git a/radicle-cob/src/change/store.rs b/radicle-cob/src/change/store.rs new file mode 100644 index 00000000..6bf2e426 --- /dev/null +++ b/radicle-cob/src/change/store.rs @@ -0,0 +1,125 @@ +// Copyright © 2022 The Radicle Link Contributors +// +// This file is part of radicle-link, distributed under the GPLv3 with Radicle +// Linking Exception. For full terms see the included LICENSE file. + +use std::{error::Error, fmt}; + +use serde::{Deserialize, Serialize}; + +use crate::{ + history::{Contents, HistoryType}, + signatures, TypeName, +}; + +pub trait Storage { + type CreateError: Error + Send + Sync + 'static; + type LoadError: Error + Send + Sync + 'static; + + type ObjectId; + type Author; + type Resource; + type Signatures; + + #[allow(clippy::type_complexity)] + fn create( + &self, + author: Option, + authority: Self::Resource, + signer: &Signer, + spec: Create, + ) -> Result< + Change, + Self::CreateError, + > + where + Signer: crypto::Signer; + + #[allow(clippy::type_complexity)] + fn load( + &self, + id: Self::ObjectId, + ) -> Result< + Change, + Self::LoadError, + >; +} + +pub struct Create { + pub typename: TypeName, + pub tips: Vec, + pub message: String, + pub contents: Contents, +} + +#[derive(Clone, Debug)] +pub struct Change { + /// The content address of the `Change` itself. + pub id: Id, + /// The content address of the tree of the `Change`. + pub revision: Id, + /// The cryptographic signatures and their public keys of the + /// authors. + pub signatures: Signatures, + /// The author of this change. The `Author` is expected to be a + /// content address to look up the identity of the author. + pub author: Option, + /// The parent resource that this change lives under. For example, + /// this change could be for a patch of a project. + pub resource: Resource, + /// The manifest describing the type of object as well as the type + /// of history for this `Change`. + pub manifest: Manifest, + /// The contents that describe `Change`. + pub contents: Contents, +} + +impl fmt::Display for Change +where + Id: fmt::Display, +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Change {{ id: {} }}", self.id) + } +} + +impl Change { + pub fn id(&self) -> &Id { + &self.id + } + + pub fn author(&self) -> &Option { + &self.author + } + + pub fn typename(&self) -> &TypeName { + &self.manifest.typename + } + + pub fn contents(&self) -> &Contents { + &self.contents + } + + pub fn resource(&self) -> &Resource { + &self.resource + } +} + +impl Change +where + Id: AsRef<[u8]>, +{ + pub fn valid_signatures(&self) -> bool { + self.signatures + .iter() + .all(|(key, sig)| key.verify(self.revision.as_ref(), sig).is_ok()) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Manifest { + /// The name given to the type of collaborative object. + pub typename: TypeName, + /// The type of history for the collaborative oject. + pub history_type: HistoryType, +} diff --git a/radicle-cob/src/change_graph.rs b/radicle-cob/src/change_graph.rs new file mode 100644 index 00000000..4a30de24 --- /dev/null +++ b/radicle-cob/src/change_graph.rs @@ -0,0 +1,228 @@ +// Copyright © 2021 The Radicle Link Contributors +// +// This file is part of radicle-link, distributed under the GPLv3 with Radicle +// Linking Exception. For full terms see the included LICENSE file. + +use std::{ + collections::{hash_map::Entry, BTreeSet, HashMap}, + convert::TryInto, +}; + +use git_ext::Oid; +use petgraph::{ + visit::{EdgeRef, Topo, Walker}, + EdgeDirection, +}; + +use crate::{ + change, object, signatures::Signatures, Change, CollaborativeObject, ObjectId, TypeName, +}; + +mod evaluation; +use evaluation::evaluate; + +/// The graph of changes for a particular collaborative object +pub(super) struct ChangeGraph { + object_id: ObjectId, + graph: petgraph::Graph, +} + +impl ChangeGraph { + /// Load the change graph from the underlying git store by walking + /// backwards from references to the object + pub(crate) fn load<'a, S>( + storage: &S, + tip_refs: impl Iterator + 'a, + typename: &TypeName, + oid: &ObjectId, + ) -> Option + where + S: change::Storage, + { + log::info!("loading object '{}' '{}'", typename, oid); + let mut builder = GraphBuilder::default(); + let mut edges_to_process: Vec<(object::Commit, Oid)> = Vec::new(); + + // Populate the initial set of edges_to_process from the refs we have + for reference in tip_refs { + log::trace!("loading object from reference '{}'", reference.name); + match storage.load(reference.target.id) { + Ok(change) => { + let commit = reference.target.clone(); + let new_edges = builder.add_change(commit, change); + edges_to_process.extend(new_edges); + } + Err(e) => { + log::warn!( + "unable to load change from reference '{}->{}', error '{}'", + reference.name, + reference.target.id, + e + ); + } + } + } + + // Process edges until we have no more to process + while let Some((parent_commit, child_commit_id)) = edges_to_process.pop() { + log::trace!( + "loading change parent='{}', child='{}'", + parent_commit.id, + child_commit_id + ); + match storage.load(parent_commit.id) { + Ok(change) => { + let parent_commit_id = parent_commit.id; + let new_edges = builder.add_change(parent_commit, change); + edges_to_process.extend(new_edges); + builder.add_edge(child_commit_id, parent_commit_id); + } + Err(e) => { + log::warn!( + "unable to load changetree from commit '{}', error '{}'", + parent_commit.id, + e + ); + } + } + } + builder.build(*oid) + } + + /// Given a graph evaluate it to produce a collaborative object. This will + /// filter out branches of the graph which do not have valid signatures, + /// or which do not have permission to make a change, or which make a + /// change which invalidates the schema of the object + pub(crate) fn evaluate(&self) -> CollaborativeObject { + let mut roots: Vec> = self + .graph + .externals(petgraph::Direction::Incoming) + .collect(); + roots.sort(); + // This is okay because we check that the graph has a root node in + // GraphBuilder::build + let root = roots.first().unwrap(); + let typename = { + let first_node = &self.graph[*root]; + first_node.typename().clone() + }; + let topo = Topo::new(&self.graph); + let items = topo.iter(&self.graph).map(|idx| { + let node = &self.graph[idx]; + let outgoing_edges = self.graph.edges_directed(idx, EdgeDirection::Outgoing); + let child_commits = outgoing_edges + .map(|e| *self.graph[e.target()].id()) + .collect::>(); + (node, child_commits) + }); + let history = { + let root_change = &self.graph[*root]; + evaluate(*root_change.id(), items) + }; + CollaborativeObject { + typename, + history, + id: self.object_id, + } + } + + /// Get the tips of the collaborative object + pub(crate) fn tips(&self) -> BTreeSet { + self.graph + .externals(petgraph::Direction::Outgoing) + .map(|n| { + let change = &self.graph[n]; + *change.id() + }) + .collect() + } + + pub(crate) fn number_of_nodes(&self) -> u64 { + self.graph.node_count().try_into().unwrap() + } + + pub(crate) fn graphviz(&self) -> String { + let for_display = self.graph.map(|_ix, n| n.to_string(), |_ix, _e| ""); + petgraph::dot::Dot::new(&for_display).to_string() + } +} + +struct GraphBuilder { + node_indices: HashMap>, + graph: petgraph::Graph, +} + +impl Default for GraphBuilder { + fn default() -> Self { + GraphBuilder { + node_indices: HashMap::new(), + graph: petgraph::graph::Graph::new(), + } + } +} + +impl GraphBuilder { + /// Add a change to the graph which we are building up, returning any edges + /// corresponding to the parents of this node in the change graph + fn add_change( + &mut self, + commit: object::Commit, + change: Change, + ) -> impl Iterator + '_ { + let author_commit = *change.author(); + let resource_commit = *change.resource(); + let commit_id = commit.id; + if let Entry::Vacant(e) = self.node_indices.entry(commit_id) { + let ix = self.graph.add_node(change); + e.insert(ix); + } + commit.parents.into_iter().filter_map(move |parent| { + if Some(parent.id) != author_commit + && parent.id != resource_commit + && !self.has_edge(parent.id, commit_id) + { + Some((parent, commit_id)) + } else { + None + } + }) + } + + fn has_edge(&mut self, parent_id: Oid, child_id: Oid) -> bool { + let parent_ix = self.node_indices.get(&parent_id); + let child_ix = self.node_indices.get(&child_id); + match (parent_ix, child_ix) { + (Some(parent_ix), Some(child_ix)) => self.graph.contains_edge(*parent_ix, *child_ix), + _ => false, + } + } + + fn add_edge(&mut self, child: Oid, parent: Oid) { + // This panics if the child or parent ids are not in the graph already + let child_id = self + .node_indices + .get(&child) + .expect("BUG: child id expected to be in graph"); + let parent_id = self + .node_indices + .get(&parent) + .expect("BUG: parent id expected to in graph"); + self.graph.update_edge(*parent_id, *child_id, ()); + } + + fn build(self, object_id: ObjectId) -> Option { + if self + .graph + .externals(petgraph::Direction::Incoming) + .next() + .is_some() + { + Some(ChangeGraph { + object_id, + graph: self.graph, + }) + } else { + None + } + } +} diff --git a/radicle-cob/src/change_graph/evaluation.rs b/radicle-cob/src/change_graph/evaluation.rs new file mode 100644 index 00000000..b7716956 --- /dev/null +++ b/radicle-cob/src/change_graph/evaluation.rs @@ -0,0 +1,82 @@ +// Copyright © 2021 The Radicle Link Contributors +// +// This file is part of radicle-link, distributed under the GPLv3 with Radicle +// Linking Exception. For full terms see the included LICENSE file. + +use std::{collections::HashMap, ops::ControlFlow}; + +use git_ext::Oid; + +use crate::{change::Change, history, pruning_fold}; + +/// # Panics +/// +/// If the change corresponding to the root OID is not in `items` +pub fn evaluate<'b>( + root: Oid, + items: impl Iterator)>, +) -> history::History { + let entries = pruning_fold::pruning_fold( + HashMap::new(), + items.map(|(change, children)| ChangeWithChildren { + change, + child_commits: children, + }), + |mut entries, c| match evaluate_change(c.change, &c.child_commits) { + Err(RejectionReason::InvalidSignatures) => { + log::warn!( + "rejecting change '{}' because its signatures were invalid", + c.change.id(), + ); + ControlFlow::Break(entries) + } + Ok(entry) => { + log::trace!("change '{}' accepted", c.change.id()); + entries.insert((*c.change.id()).into(), entry); + ControlFlow::Continue(entries) + } + }, + ); + // SAFETY: The caller must guarantee that `root` is in `items` + history::History::new(root, entries).unwrap() +} + +fn evaluate_change( + change: &Change, + child_commits: &[Oid], +) -> Result { + // Check the change signatures are valid + if !change.valid_signatures() { + return Err(RejectionReason::InvalidSignatures); + }; + + Ok(history::Entry::new( + *change.id(), + *change.author(), + change.resource, + child_commits.iter().cloned(), + change.contents().clone(), + )) +} + +struct ChangeWithChildren<'a> { + change: &'a Change, + child_commits: Vec, +} + +impl<'a> pruning_fold::GraphNode for ChangeWithChildren<'a> { + type Id = Oid; + + fn id(&self) -> &Self::Id { + self.change.id() + } + + fn child_ids(&self) -> &[Self::Id] { + &self.child_commits + } +} + +#[derive(Debug)] +enum RejectionReason { + InvalidSignatures, +} diff --git a/radicle-cob/src/history.rs b/radicle-cob/src/history.rs new file mode 100644 index 00000000..08015881 --- /dev/null +++ b/radicle-cob/src/history.rs @@ -0,0 +1,162 @@ +// Copyright © 2021 The Radicle Link Contributors +// +// This file is part of radicle-link, distributed under the GPLv3 with Radicle +// Linking Exception. For full terms see the included LICENSE file. + +use std::{ + collections::{BTreeSet, HashMap}, + ops::ControlFlow, +}; + +use git_ext::Oid; +use petgraph::visit::Walker as _; + +use crate::pruning_fold; + +pub mod entry; +pub use entry::{Contents, Entry, EntryId}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum HistoryType { + Automerge, +} + +/// The DAG of changes making up the history of a collaborative object. +/// +/// The `Author` represents the content address for the author of the change entry. +#[derive(Clone, Debug)] +pub struct History { + graph: petgraph::Graph, + indices: HashMap>, +} + +impl PartialEq for History { + fn eq(&self, other: &Self) -> bool { + self.tips() == other.tips() + } +} + +impl Eq for History {} + +#[derive(Debug, thiserror::Error)] +pub enum CreateError { + #[error("no entry for the root ID in the entries")] + MissingRoot, +} + +impl History { + pub(crate) fn new_from_root( + id: Id, + author: Option, + resource: Oid, + contents: Contents, + ) -> Self + where + Id: Into, + { + let id = id.into(); + let root_entry = Entry { + id, + author, + resource, + children: vec![], + contents, + }; + let mut entries = HashMap::new(); + entries.insert(id, root_entry.clone()); + let NewGraph { graph, indices } = create_petgraph(&root_entry.id, &entries); + Self { graph, indices } + } + + pub fn new(root: Id, entries: HashMap) -> Result + where + Id: Into, + { + let root = root.into(); + if !entries.contains_key(&root) { + Err(CreateError::MissingRoot) + } else { + let NewGraph { graph, indices } = create_petgraph(&root, &entries); + Ok(Self { graph, indices }) + } + } + + /// A topological (parents before children) traversal of the dependency + /// graph of this history. This is analagous to + /// [`std::iter::Iterator::fold`] in that it folds every change into an + /// accumulator value of type `A`. However, unlike `fold` the function `f` + /// may prune branches from the dependency graph by returning + /// `ControlFlow::Break`. + pub fn traverse(&self, init: A, f: F) -> A + where + F: for<'r> FnMut(A, &'r Entry) -> ControlFlow, + { + let topo = petgraph::visit::Topo::new(&self.graph); + #[allow(clippy::let_and_return)] + let items = topo.iter(&self.graph).map(|idx| { + let node = &self.graph[idx]; + node + }); + pruning_fold::pruning_fold(init, items, f) + } + + pub(crate) fn tips(&self) -> BTreeSet { + self.graph + .externals(petgraph::Direction::Outgoing) + .map(|n| { + let entry = &self.graph[n]; + (*entry.id()).into() + }) + .collect() + } + + pub(crate) fn extend( + &mut self, + new_id: Id, + new_author: Option, + new_resource: Oid, + new_contents: Contents, + ) where + Id: Into, + { + let tips = self.tips(); + let new_id = new_id.into(); + let new_entry = Entry::new( + new_id, + new_author, + new_resource, + std::iter::empty::(), + new_contents, + ); + let new_ix = self.graph.add_node(new_entry); + for tip in tips { + let tip_ix = self.indices.get(&tip.into()).unwrap(); + self.graph.update_edge(*tip_ix, new_ix, ()); + } + } +} + +struct NewGraph { + graph: petgraph::Graph, + indices: HashMap>, +} + +fn create_petgraph<'a>(root: &'a EntryId, entries: &'a HashMap) -> NewGraph { + let mut graph = petgraph::Graph::new(); + let mut indices = HashMap::>::new(); + let root = entries.get(root).unwrap().clone(); + let root_ix = graph.add_node(root.clone()); + indices.insert(root.id, root_ix); + let mut to_process = vec![root]; + while let Some(entry) = to_process.pop() { + let entry_ix = indices[&entry.id]; + for child_id in entry.children { + let child = entries[&child_id].clone(); + let child_ix = graph.add_node(child.clone()); + indices.insert(child.id, child_ix); + graph.update_edge(entry_ix, child_ix, ()); + to_process.push(child.clone()); + } + } + NewGraph { graph, indices } +} diff --git a/radicle-cob/src/history/entry.rs b/radicle-cob/src/history/entry.rs new file mode 100644 index 00000000..04bfe8a7 --- /dev/null +++ b/radicle-cob/src/history/entry.rs @@ -0,0 +1,133 @@ +// Copyright © 2021 The Radicle Link Contributors +// +// This file is part of radicle-link, distributed under the GPLv3 with Radicle +// Linking Exception. For full terms see the included LICENSE file. + +use git_ext::Oid; + +use crate::pruning_fold; + +use super::HistoryType; + +#[derive(Clone, Debug, PartialEq, Hash, Eq)] +pub enum Contents { + Automerge(Vec), +} + +impl Contents { + pub fn automerge(bytes: &[u8]) -> Self { + Self::Automerge(bytes.to_vec()) + } +} + +impl From<&Contents> for HistoryType { + fn from(c: &Contents) -> Self { + match c { + Contents::Automerge(..) => HistoryType::Automerge, + } + } +} + +impl AsRef<[u8]> for Contents { + fn as_ref(&self) -> &[u8] { + match self { + Self::Automerge(bytes) => bytes, + } + } +} + +/// A unique identifier for a history entry. +#[derive(Clone, Copy, Debug, PartialEq, Hash, Eq, PartialOrd, Ord)] +pub struct EntryId(Oid); + +impl From for EntryId { + fn from(id: git2::Oid) -> Self { + Self(id.into()) + } +} + +impl From for EntryId { + fn from(id: Oid) -> Self { + Self(id) + } +} + +impl From for Oid { + fn from(EntryId(id): EntryId) -> Self { + id + } +} + +/// One entry in the dependency graph for a change +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Entry { + /// The identifier for this entry + pub(super) id: EntryId, + /// The content-address for this entry's author. + pub(super) author: Option, + /// The content-address for the resource this entry lives under. + pub(super) resource: Oid, + /// The child entries for this entry. + pub(super) children: Vec, + /// The contents of this entry. + pub(super) contents: Contents, +} + +impl Entry { + pub fn new( + id: Id1, + author: Option, + resource: Oid, + children: ChildIds, + contents: Contents, + ) -> Self + where + Id1: Into, + Id2: Into, + ChildIds: IntoIterator, + { + Self { + id: id.into(), + author, + resource, + children: children.into_iter().map(|id| id.into()).collect(), + contents, + } + } + + /// The ids of the changes this change depends on + pub fn children(&self) -> impl Iterator { + self.children.iter() + } + + /// The `Oid` of the resource this change lives under. + pub fn resource(&self) -> Oid { + self.resource + } + + /// The `Oid` of the author that made this change. + pub fn author(&self) -> &Option { + &self.author + } + + /// The contents of this change + pub fn contents(&self) -> &Contents { + &self.contents + } + + pub fn id(&self) -> &EntryId { + &self.id + } +} + +impl pruning_fold::GraphNode for Entry { + type Id = EntryId; + + fn id(&self) -> &Self::Id { + &self.id + } + + fn child_ids(&self) -> &[Self::Id] { + &self.children + } +} diff --git a/radicle-cob/src/identity.rs b/radicle-cob/src/identity.rs new file mode 100644 index 00000000..359d8805 --- /dev/null +++ b/radicle-cob/src/identity.rs @@ -0,0 +1,29 @@ +// Copyright © 2022 The Radicle Link Contributors +// +// This file is part of radicle-link, distributed under the GPLv3 with Radicle +// Linking Exception. For full terms see the included LICENSE file. + +use git_ext::Oid; + +/// An [`Identity`] represents a content addressed identity +/// (i.e. expected to be stored in a git backend). +/// +/// It should have: +/// * A delegate system +/// * A content addressable identifier +/// * A unique, stable identifier +pub trait Identity { + type Identifier; + + /// Confirm that the given [`crypto::PublicKey`] is a delegate for + /// the identity. + fn is_delegate(&self, delegation: &crypto::PublicKey) -> bool; + + /// Provide the content address for the given identity. This is + /// expected to be the latest address for the identity at the time + /// of use. + fn content_id(&self) -> Oid; + + /// The unique, stable identifier for the identity. + fn identifier(&self) -> Self::Identifier; +} diff --git a/radicle-cob/src/lib.rs b/radicle-cob/src/lib.rs new file mode 100644 index 00000000..36b9340d --- /dev/null +++ b/radicle-cob/src/lib.rs @@ -0,0 +1,133 @@ +// Copyright © 2021 The Radicle Link Contributors +// +// This file is part of radicle-link, distributed under the GPLv3 with Radicle +// Linking Exception. For full terms see the included LICENSE file. + +//! # Collaborative Objects +//! +//! Collaborative objects are graphs of CRDTs. The current CRDTs that +//! is intended to be used are specifically [automerge] CRDTs. +//! +//! The initial design is proposed at [RFC-0662], and this +//! implementation keeps to most of its design principle. +//! +//! ## Basic Types +//! +//! The basic types that are found in `radicle-cob` are: +//! * [`CollaborativeObject`] -- the computed object itself. +//! * [`ObjectId`] -- the content-address for a single collaborative +//! object. +//! * [`TypeName`] -- the name for a collection of collaborative objects. +//! * [`History`] -- the traversable history of the changes made to +//! a single collaborative object. +//! +//! ## CRU Interface (No Delete) +//! +//! The main entry for manipulating [`CollaborativeObject`]s is by +//! using the CRU like functions: +//! * [`create`] +//! * [`get`] +//! * [`list`] +//! * [`update`] +//! +//! ## Storage +//! +//! The storing of collaborative objects is based on a git +//! backend. The previously mentioned functions all accept a [`Store`] +//! as parameter. The `Store` itself is an accumulation of different +//! storage capabilities: +//! * [`object::Storage`] +//! * [`change::Storage`] -- **Note**: there is already an +//! implementation for this for [`git2::Repository`] for convenience. +//! +//! The `Store` also takes a generic parameter to indicate the type of +//! resource the collaborative object is living inside, for example a +//! software project. This `Resource` must also implement +//! [`identity::Identity`] to allow the internal logic to reference +//! the resource's content-address in `git` as well as the stable +//! identifier used for the resource. +//! +//! ## History Traversal +//! +//! The [`History`] of a [`CollaborativeObject`] -- accessed via +//! [`CollaborativeObject::history`] -- has a method +//! [`History::traverse`] which provides a way of inspecting each +//! [`Entry`] and building up a final value. +//! +//! This mechanism would be used in tandem with [automerge] to load an +//! automerge document and deserialize into an application defined +//! object. +//! +//! This traversal is also the point at which the [`Entry::author`] +//! and [`Entry::resource`] can be retrieved to apply any kind of +//! filtering logic. For example, a specific `author`'s change may be +//! egregious, spouting terrible libel about Radicle. It is at this +//! point that the `author`'s change can be filtered out from the +//! final product of the traversal. +//! +//! [automerge]: https://automerge.org +//! [RFC-0662]: https://github.com/radicle-dev/radicle-link/blob/master/docs/rfc/0662-collaborative-objects.adoc + +extern crate radicle_crypto as crypto; +extern crate radicle_git_ext as git_ext; + +mod backend; +pub use backend::git; + +mod change_graph; +mod trailers; + +pub mod change; +pub use change::Change; + +pub mod identity; + +pub mod history; +pub use history::{Contents, Entry, History, HistoryType}; + +mod pruning_fold; + +pub mod signatures; +use signatures::Signatures; + +pub mod type_name; +pub use type_name::TypeName; + +pub mod object; +pub use object::{create, get, info, list, update, CollaborativeObject, Create, ObjectId, Update}; + +#[cfg(test)] +mod test; + +#[cfg(test)] +mod tests; + +/// The `Store` is an aggregation of the different types of storage +/// traits required for editing [`CollaborativeObject`]s. +/// +/// The backing store being used is expected to be a `git` backend. +/// +/// To get started using this trait, you must implement the following +/// for the specific `git` storage: +/// +/// * [`object::Storage`] +/// * [`identity::Identity`] for `Resource` +/// +/// **Note**: [`change::Storage`] is already implemented for +/// [`git2::Repository`]. It is expected that the underlying storage +/// for `object::Storage` will also be `git2::Repository`, but if not +/// please open an issue to change the definition of `Store` :) +pub trait Store +where + Resource: identity::Identity, + Self: object::Storage + + change::Storage< + CreateError = git::change::error::Create, + LoadError = git::change::error::Load, + ObjectId = git_ext::Oid, + Author = git_ext::Oid, + Resource = git_ext::Oid, + Signatures = Signatures, + >, +{ +} diff --git a/radicle-cob/src/object.rs b/radicle-cob/src/object.rs new file mode 100644 index 00000000..d23561e7 --- /dev/null +++ b/radicle-cob/src/object.rs @@ -0,0 +1,85 @@ +// Copyright © 2022 The Radicle Link Contributors +// +// This file is part of radicle-link, distributed under the GPLv3 with Radicle +// Linking Exception. For full terms see the included LICENSE file. + +use std::{convert::TryFrom as _, fmt, str::FromStr}; + +use git_ext::Oid; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +pub mod collaboration; +pub use collaboration::{create, get, info, list, update, CollaborativeObject, Create, Update}; + +pub mod storage; +pub use storage::{Commit, Objects, Reference, Storage}; + +#[derive(Debug, Error)] +pub enum ParseObjectId { + #[error(transparent)] + Git(#[from] git2::Error), +} + +/// The id of an object +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ObjectId(Oid); + +impl FromStr for ObjectId { + type Err = ParseObjectId; + + fn from_str(s: &str) -> Result { + let oid = Oid::try_from(s.as_bytes())?; + Ok(ObjectId(oid)) + } +} + +impl From for ObjectId { + fn from(oid: Oid) -> Self { + ObjectId(oid) + } +} + +impl From<&Oid> for ObjectId { + fn from(oid: &Oid) -> Self { + (*oid).into() + } +} + +impl From for ObjectId { + fn from(oid: git2::Oid) -> Self { + Oid::from(oid).into() + } +} + +impl From<&git2::Oid> for ObjectId { + fn from(oid: &git2::Oid) -> Self { + ObjectId(Oid::from(*oid)) + } +} + +impl fmt::Display for ObjectId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Serialize for ObjectId { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_bytes(self.0.as_bytes()) + } +} + +impl<'de> Deserialize<'de> for ObjectId { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = <&[u8]>::deserialize(deserializer)?; + let oid = Oid::try_from(raw).map_err(serde::de::Error::custom)?; + Ok(ObjectId(oid)) + } +} diff --git a/radicle-cob/src/object/collaboration.rs b/radicle-cob/src/object/collaboration.rs new file mode 100644 index 00000000..8b46538d --- /dev/null +++ b/radicle-cob/src/object/collaboration.rs @@ -0,0 +1,55 @@ +// Copyright © 2022 The Radicle Link Contributors +// +// This file is part of radicle-link, distributed under the GPLv3 with Radicle +// Linking Exception. For full terms see the included LICENSE file. + +use std::collections::BTreeSet; + +use git_ext::Oid; + +use crate::{change, identity::Identity, Contents, History, ObjectId, TypeName}; + +pub mod error; + +mod create; +pub use create::{create, Create}; + +mod get; +pub use get::get; + +pub mod info; + +mod list; +pub use list::list; + +mod update; +pub use update::{update, Update}; + +/// A collaborative object +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CollaborativeObject { + /// The typename of this object + pub(crate) typename: TypeName, + /// The CRDT history we know about for this object + pub(crate) history: History, + /// The id of the object + pub(crate) id: ObjectId, +} + +impl CollaborativeObject { + pub fn history(&self) -> &History { + &self.history + } + + pub fn id(&self) -> &ObjectId { + &self.id + } + + pub fn typename(&self) -> &TypeName { + &self.typename + } + + fn tips(&self) -> BTreeSet { + self.history.tips().into_iter().map(Oid::from).collect() + } +} diff --git a/radicle-cob/src/object/collaboration/create.rs b/radicle-cob/src/object/collaboration/create.rs new file mode 100644 index 00000000..937fd751 --- /dev/null +++ b/radicle-cob/src/object/collaboration/create.rs @@ -0,0 +1,99 @@ +// Copyright © 2022 The Radicle Link Contributors +// +// This file is part of radicle-link, distributed under the GPLv3 with Radicle +// Linking Exception. For full terms see the included LICENSE file. + +use crate::Store; + +use super::*; + +/// The metadata required for creating a new [`CollaborativeObject`]. +pub struct Create { + /// The identity of the author for this object's first change. + pub author: Option, + /// The CRDT history to initialize this object with. + pub contents: Contents, + /// The typename for this object. + pub typename: TypeName, + /// The message to add when creating this object. + pub message: String, +} + +impl Create { + fn create_spec(&self) -> change::Create { + change::Create { + typename: self.typename.clone(), + tips: Vec::new(), + message: self.message.clone(), + contents: self.contents.clone(), + } + } +} + +/// Create a new [`CollaborativeObject`]. +/// +/// The `storage` is the backing storage for storing +/// [`crate::Change`]s at content-addressable locations. Please see +/// [`Store`] for further information. +/// +/// The `signer` is expected to be a cryptographic signing key. This +/// ensures that the objects origin is cryptographically verifiable. +/// +/// The `resource` is the parent of this object, for example a +/// software project. +/// +/// The `args` are the metadata for this [`CollaborativeObject`]. See +/// [`Create`] for further information. +pub fn create( + storage: &S, + signer: Signer, + resource: &Resource, + args: Create, +) -> Result +where + S: Store, + Author: Identity, + Author::Identifier: Clone + PartialEq, + Resource: Identity, + Signer: crypto::Signer, +{ + let Create { + author, + ref contents, + ref typename, + .. + } = &args; + + let content = match author { + None => None, + Some(author) => { + if !author.is_delegate(signer.public_key()) { + return Err(error::Create::SignerIsNotAuthor); + } else { + Some(author.content_id()) + } + } + }; + + let init_change = storage + .create(content, resource.content_id(), &signer, args.create_spec()) + .map_err(error::Create::from)?; + + let history = History::new_from_root( + *init_change.id(), + content, + resource.content_id(), + contents.clone(), + ); + + let object_id = init_change.id().into(); + storage + .update(&resource.identifier(), typename, &object_id, &init_change) + .map_err(|err| error::Create::Refs { err: Box::new(err) })?; + + Ok(CollaborativeObject { + typename: args.typename, + history, + id: init_change.id().into(), + }) +} diff --git a/radicle-cob/src/object/collaboration/error.rs b/radicle-cob/src/object/collaboration/error.rs new file mode 100644 index 00000000..b79c9997 --- /dev/null +++ b/radicle-cob/src/object/collaboration/error.rs @@ -0,0 +1,57 @@ +// Copyright © 2022 The Radicle Link Contributors +// +// This file is part of radicle-link, distributed under the GPLv3 with Radicle +// Linking Exception. For full terms see the included LICENSE file. + +use thiserror::Error; + +use crate::git; + +#[derive(Debug, Error)] +pub enum Create { + #[error("Invalid automerge history")] + InvalidAutomergeHistory, + #[error(transparent)] + CreateChange(#[from] git::change::error::Create), + #[error("failed to updated references for during object creation")] + Refs { + #[source] + err: Box, + }, + #[error(transparent)] + Io(#[from] std::io::Error), + #[error("signer must belong to the author")] + SignerIsNotAuthor, +} + +#[derive(Debug, Error)] +pub enum Retrieve { + #[error(transparent)] + Git(#[from] git2::Error), + #[error("failed to get references during object retrieval")] + Refs { + #[source] + err: Box, + }, + #[error(transparent)] + Io(#[from] std::io::Error), +} + +#[derive(Debug, Error)] +pub enum Update { + #[error("no object found")] + NoSuchObject, + #[error(transparent)] + CreateChange(#[from] git::change::error::Create), + #[error("failed to get references during object update")] + Refs { + #[source] + err: Box, + }, + #[error(transparent)] + Git(#[from] git2::Error), + #[error(transparent)] + Io(#[from] std::io::Error), + #[error("signer must belong to the author")] + SignerIsNotAuthor, +} diff --git a/radicle-cob/src/object/collaboration/get.rs b/radicle-cob/src/object/collaboration/get.rs new file mode 100644 index 00000000..d88ba7e6 --- /dev/null +++ b/radicle-cob/src/object/collaboration/get.rs @@ -0,0 +1,35 @@ +// Copyright © 2022 The Radicle Link Contributors +// +// This file is part of radicle-link, distributed under the GPLv3 with Radicle +// Linking Exception. For full terms see the included LICENSE file. + +use crate::{change_graph::ChangeGraph, identity, CollaborativeObject, ObjectId, Store, TypeName}; + +use super::error; + +/// Get a [`CollaborativeObject`], if it exists. +/// +/// The `storage` is the backing storage for storing +/// [`crate::Change`]s at content-addressable locations. Please see +/// [`Store`] for further information. +/// +/// The `resource` is the parent of this object, for example a +/// software project. +/// +/// The `typename` is the type of object to be found, while the `oid` +/// is the identifier for the particular object under that type. +pub fn get( + storage: &S, + resource: &Resource, + typename: &TypeName, + oid: &ObjectId, +) -> Result, error::Retrieve> +where + S: Store, + Resource: identity::Identity, +{ + let tip_refs = storage + .objects(&resource.identifier(), typename, oid) + .map_err(|err| error::Retrieve::Refs { err: Box::new(err) })?; + Ok(ChangeGraph::load(storage, tip_refs.iter(), typename, oid).map(|graph| graph.evaluate())) +} diff --git a/radicle-cob/src/object/collaboration/info.rs b/radicle-cob/src/object/collaboration/info.rs new file mode 100644 index 00000000..55783473 --- /dev/null +++ b/radicle-cob/src/object/collaboration/info.rs @@ -0,0 +1,67 @@ +// Copyright © 2022 The Radicle Link Contributors +// +// This file is part of radicle-link, distributed under the GPLv3 with Radicle +// Linking Exception. For full terms see the included LICENSE file. + +//! [`ChangeGraphInfo`] provides a useful debugging structure for +//! represnting a single [`crate::CollaborativeObject`]'s underlying +//! change graph. This includes a [`ChangeGraphInfo::dotviz`] for +//! describing the graph via [graphviz]. +//! +//! [graphviz]: https://graphviz.org/ + +use std::collections::BTreeSet; + +use git_ext::Oid; + +use crate::{change_graph::ChangeGraph, identity::Identity, ObjectId, Store, TypeName}; + +use super::error; + +/// Additional information about the change graph of an object +pub struct ChangeGraphInfo { + /// The ID of the object + pub object_id: ObjectId, + /// A graphviz description of the changegraph of the object + pub dotviz: String, + /// The number of nodes in the change graph of the object + pub number_of_nodes: u64, + /// The "tips" of the change graph, i.e the object IDs pointed to by + /// references to the object + pub tips: BTreeSet, +} + +/// Retrieve additional information about the change graph of an object. This +/// is mostly useful for debugging and testing +/// +/// The `storage` is the backing storage for storing +/// [`crate::Change`]s at content-addressable locations. Please see +/// [`Store`] for further information. +/// +/// The `resource` is the parent of this object, for example a +/// software project. +/// +/// The `typename` is the type of object to be found, while the `oid` +/// is the identifier for the particular object under that type. +pub fn changegraph( + storage: &S, + resource: &Resource, + typename: &TypeName, + oid: &ObjectId, +) -> Result, error::Retrieve> +where + S: Store, + Resource: Identity, +{ + let tip_refs = storage + .objects(&resource.identifier(), typename, oid) + .map_err(|err| error::Retrieve::Refs { err: Box::new(err) })?; + Ok( + ChangeGraph::load(storage, tip_refs.iter(), typename, oid).map(|graph| ChangeGraphInfo { + object_id: *oid, + dotviz: graph.graphviz(), + number_of_nodes: graph.number_of_nodes(), + tips: graph.tips(), + }), + ) +} diff --git a/radicle-cob/src/object/collaboration/list.rs b/radicle-cob/src/object/collaboration/list.rs new file mode 100644 index 00000000..ff988111 --- /dev/null +++ b/radicle-cob/src/object/collaboration/list.rs @@ -0,0 +1,50 @@ +// Copyright © 2022 The Radicle Link Contributors +// +// This file is part of radicle-link, distributed under the GPLv3 with Radicle +// Linking Exception. For full terms see the included LICENSE file. + +use crate::{change_graph::ChangeGraph, identity::Identity, CollaborativeObject, Store, TypeName}; + +use super::error; + +/// List a set of [`CollaborativeObject`]. +/// +/// The `storage` is the backing storage for storing +/// [`crate::Change`]s at content-addressable locations. Please see +/// [`Store`] for further information. +/// +/// The `resource` is the parent of this object, for example a +/// software project. +/// +/// The `typename` is the type of objects to listed. +pub fn list( + storage: &S, + resource: &Resource, + typename: &TypeName, +) -> Result, error::Retrieve> +where + S: Store, + Resource: Identity, +{ + let references = storage + .types(&resource.identifier(), typename) + .map_err(|err| error::Retrieve::Refs { err: Box::new(err) })?; + log::trace!("loaded {} references", references.len()); + let mut result = Vec::new(); + for (oid, tip_refs) in references { + log::trace!("loading object '{}'", oid); + let loaded = ChangeGraph::load(storage, tip_refs.iter(), typename, &oid) + .map(|graph| graph.evaluate()); + + match loaded { + Some(obj) => { + log::trace!("object '{}' found", oid); + result.push(obj); + } + None => { + log::trace!("object '{}' not found", oid); + } + } + } + Ok(result) +} diff --git a/radicle-cob/src/object/collaboration/update.rs b/radicle-cob/src/object/collaboration/update.rs new file mode 100644 index 00000000..634762b4 --- /dev/null +++ b/radicle-cob/src/object/collaboration/update.rs @@ -0,0 +1,101 @@ +// Copyright © 2022 The Radicle Link Contributors +// +// This file is part of radicle-link, distributed under the GPLv3 with Radicle +// Linking Exception. For full terms see the included LICENSE file. + +use crate::{ + change, change_graph::ChangeGraph, identity::Identity, CollaborativeObject, Contents, ObjectId, + Store, TypeName, +}; + +use super::error; + +/// The data required to update an object +pub struct Update { + /// The identity of the author for this object's first change. + pub author: Option, + /// The CRDT changes to add to the object. + pub changes: Contents, + /// The object ID of the object to be updated. + pub object_id: ObjectId, + /// The typename of the object to be updated. + pub typename: TypeName, + /// The message to add when updating this object. + pub message: String, +} + +/// Update an existing [`CollaborativeObject`]. +/// +/// The `storage` is the backing storage for storing +/// [`crate::Change`]s at content-addressable locations. Please see +/// [`Store`] for further information. +/// +/// The `signer` is expected to be a cryptographic signing key. This +/// ensures that the objects origin is cryptographically verifiable. +/// +/// The `resource` is the parent of this object, for example a +/// software project. +/// +/// The `args` are the metadata for this [`CollaborativeObject`] +/// udpate. See [`Update`] for further information. +pub fn update( + storage: &S, + signer: Signer, + resource: &Resource, + args: Update, +) -> Result +where + S: Store, + Author: Identity, + Author::Identifier: Clone + PartialEq, + Resource: Identity, + Signer: crypto::Signer, +{ + let Update { + author, + ref typename, + object_id, + changes, + message, + } = args; + + let content = match author { + None => None, + Some(author) => { + if !author.is_delegate(signer.public_key()) { + return Err(error::Update::SignerIsNotAuthor); + } else { + Some(author.content_id()) + } + } + }; + + let existing_refs = storage + .objects(&resource.identifier(), typename, &object_id) + .map_err(|err| error::Update::Refs { err: Box::new(err) })?; + + let mut object = ChangeGraph::load(storage, existing_refs.iter(), typename, &object_id) + .map(|graph| graph.evaluate()) + .ok_or(error::Update::NoSuchObject)?; + + let change = storage.create( + content, + resource.content_id(), + &signer, + change::Create { + tips: object.tips().iter().cloned().collect(), + contents: changes.clone(), + typename: typename.clone(), + message, + }, + )?; + + object + .history + .extend(change.id, content, change.resource, changes); + storage + .update(&resource.identifier(), typename, &object_id, &change) + .map_err(|err| error::Update::Refs { err: Box::new(err) })?; + + Ok(object) +} diff --git a/radicle-cob/src/object/storage.rs b/radicle-cob/src/object/storage.rs new file mode 100644 index 00000000..5cc8e130 --- /dev/null +++ b/radicle-cob/src/object/storage.rs @@ -0,0 +1,85 @@ +// Copyright © 2021 The Radicle Link Contributors +// +// This file is part of radicle-link, distributed under the GPLv3 with Radicle +// Linking Exception. For full terms see the included LICENSE file. + +use std::{collections::HashMap, error::Error}; + +use git_ext::Oid; +use git_ref_format::RefString; + +use crate::change::Change; +use crate::{ObjectId, TypeName}; + +/// The [`Reference`]s that refer to the commits that make up a +/// [`crate::CollaborativeObject`]. +#[derive(Clone, Debug)] +pub struct Objects { + /// If the local peer has a [`Reference`] for this particular + /// object, then `local` should be set. + pub local: Option, + /// The `remotes` are the entries for each remote peer's version + /// of the particular object. + pub remotes: Vec, +} + +impl Objects { + /// Return an iterator over the `local` and `remotes` of the given + /// [`Objects`]. + pub fn iter(&self) -> impl Iterator { + self.local.iter().chain(self.remotes.iter()) + } +} + +/// A [`Reference`] that must directly point to the [`Commit`] for a +/// [`crate::CollaborativeObject`]. +#[derive(Clone, Debug)] +pub struct Reference { + /// The `name` of the reference. + pub name: RefString, + /// The [`Commit`] that this reference points to. + pub target: Commit, +} + +/// A [`Commit`] that holds the data for a given [`crate::CollaborativeObject`]. +#[derive(Clone, Debug)] +pub struct Commit { + /// The content identifier of the commit. + pub id: Oid, + /// The parents of the commit. + pub parents: Vec, +} + +pub trait Storage { + type ObjectsError: Error + Send + Sync + 'static; + type TypesError: Error + Send + Sync + 'static; + type UpdateError: Error + Send + Sync + 'static; + + type Identifier; + + /// Get all references which point to a head of the change graph for a + /// particular object + fn objects( + &self, + identifier: &Self::Identifier, + typename: &TypeName, + object_id: &ObjectId, + ) -> Result; + + /// Get all references to objects of a given type within a particular + /// identity + fn types( + &self, + identifier: &Self::Identifier, + typename: &TypeName, + ) -> Result, Self::TypesError>; + + /// Update a ref to a particular collaborative object + fn update( + &self, + identifier: &Self::Identifier, + typename: &TypeName, + object_id: &ObjectId, + change: &Change, + ) -> Result<(), Self::UpdateError>; +} diff --git a/radicle-cob/src/pruning_fold.rs b/radicle-cob/src/pruning_fold.rs new file mode 100644 index 00000000..73561c5a --- /dev/null +++ b/radicle-cob/src/pruning_fold.rs @@ -0,0 +1,100 @@ +// Copyright © 2021 The Radicle Link Contributors +// +// This file is part of radicle-link, distributed under the GPLv3 with Radicle +// Linking Exception. For full terms see the included LICENSE file. + +use std::{ + borrow::Borrow, + collections::{BTreeSet, HashMap}, + ops::ControlFlow, +}; + +pub(crate) trait GraphNode { + type Id: Clone + Eq + Ord + std::hash::Hash + std::fmt::Debug; + + fn id(&self) -> &Self::Id; + fn child_ids(&self) -> &[Self::Id]; +} + +/// Fold a topological sort of a directed acyclic graph, pruning some branches. +/// +/// `items` must be an iterator over the nodes of the graph in topological +/// order. Assuming this is the case `fold` will only be called with nodes whose +/// ancestors have already been evaluated. Returning `ControlFlow::Break(..)` +/// from `fold` will omit evaluation of the current node and consequently omit +/// processing of any nodes who have the current node as an ancestor. +pub(crate) fn pruning_fold<'a, BN, Node, It, F, O>(init: O, items: It, mut f: F) -> O +where + BN: Borrow + 'a, + Node: 'a + GraphNode, + It: Iterator, + F: for<'r> FnMut(O, &'r Node) -> std::ops::ControlFlow, +{ + let mut rejected = RejectedNodes::new(); + let mut state = init; + for node in items { + // There can be multiple paths to a change so in a topological traversal we + // might encounter a change which we have already rejected + // previously + if rejected.is_rejected(node.borrow().id()) { + continue; + } + if let Some(rejected_ancestor) = rejected.rejected_ancestor(node.borrow().id()) { + let ancestor = rejected_ancestor.clone(); + log::warn!( + "rejecting node because an ancestor change was rejected id='{:?}', ancestor='{:?}'", + node.borrow().id(), + rejected_ancestor + ); + for child in node.borrow().child_ids() { + rejected.transitively_reject(child, &ancestor); + } + continue; + } + state = match f(state, node.borrow()) { + ControlFlow::Continue(state) => state, + ControlFlow::Break(state) => { + rejected.directly_reject(node.borrow().id(), node.borrow().child_ids()); + state + } + }; + } + state +} + +struct RejectedNodes { + /// Changes which are directly rejected by the fold function + direct: BTreeSet, + /// A map from node IDs to the IDs of ancestor nodes which are + /// direct rejections + transitive: HashMap, +} + +impl RejectedNodes { + fn new() -> RejectedNodes { + RejectedNodes { + direct: BTreeSet::new(), + transitive: HashMap::new(), + } + } + + fn rejected_ancestor(&self, node: &NodeId) -> Option<&NodeId> { + self.transitive.get(node) + } + + fn is_rejected(&self, node: &NodeId) -> bool { + self.direct.contains(node) + } + + fn directly_reject(&mut self, node: &NodeId, children: &[NodeId]) { + self.direct.insert(node.clone()); + for child in children { + self.transitive.insert(child.clone(), node.clone()); + } + } + + fn transitively_reject(&mut self, child: &NodeId, rejected_ancestor: &NodeId) { + self.transitive + .insert(child.clone(), rejected_ancestor.clone()); + } +} diff --git a/radicle-cob/src/sign.rs b/radicle-cob/src/sign.rs new file mode 100644 index 00000000..1cf94bab --- /dev/null +++ b/radicle-cob/src/sign.rs @@ -0,0 +1,7 @@ +// Copyright © 2022 The Radicle Link Contributors +// +// This file is part of radicle-link, distributed under the GPLv3 with Radicle +// Linking Exception. For full terms see the included LICENSE file. + +pub mod signatures; +pub use signatures::{Signature, Signatures}; diff --git a/radicle-cob/src/signatures.rs b/radicle-cob/src/signatures.rs new file mode 100644 index 00000000..574a8946 --- /dev/null +++ b/radicle-cob/src/signatures.rs @@ -0,0 +1,144 @@ +// Copyright © 2019-2020 The Radicle Foundation +// +// This file is part of radicle-link, distributed under the GPLv3 with Radicle +// Linking Exception. For full terms see the included LICENSE file. + +use std::{ + collections::BTreeMap, + convert::TryFrom, + iter::FromIterator, + ops::{Deref, DerefMut}, +}; + +use crypto::{ssh::ExtendedSignature, PublicKey}; +use git_commit::{ + Commit, + Signature::{Pgp, Ssh}, +}; + +pub mod error; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Signature { + key: PublicKey, + sig: crypto::Signature, +} + +impl From for ExtendedSignature { + fn from(sig: Signature) -> Self { + Self::new(sig.key, sig.sig) + } +} + +impl From for Signature { + fn from(ex: ExtendedSignature) -> Self { + let (key, sig) = ex.into(); + Self { key, sig } + } +} + +impl From<(PublicKey, crypto::Signature)> for Signature { + fn from((key, sig): (PublicKey, crypto::Signature)) -> Self { + Self { key, sig } + } +} + +// FIXME(kim): This should really be a HashMap with a no-op Hasher -- PublicKey +// collisions are catastrophic +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Signatures(BTreeMap); + +impl Deref for Signatures { + type Target = BTreeMap; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for Signatures { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl From for Signatures { + fn from(Signature { key, sig }: Signature) -> Self { + let mut map = BTreeMap::new(); + map.insert(key, sig); + map.into() + } +} + +impl From> for Signatures { + fn from(map: BTreeMap) -> Self { + Self(map) + } +} + +impl From for BTreeMap { + fn from(s: Signatures) -> Self { + s.0 + } +} + +impl TryFrom<&Commit> for Signatures { + type Error = error::Signatures; + + fn try_from(value: &Commit) -> Result { + value + .signatures() + .filter_map(|signature| { + match signature { + // Skip PGP signatures + Pgp(_) => None, + Ssh(armored) => Some( + ExtendedSignature::from_armored(armored.as_bytes()) + .map_err(error::Signatures::from), + ), + } + }) + .map(|ex| ex.map(|ex| ex.into())) + .collect::>() + } +} + +impl FromIterator<(PublicKey, crypto::Signature)> for Signatures { + fn from_iter(iter: T) -> Self + where + T: IntoIterator, + { + Self(BTreeMap::from_iter(iter)) + } +} + +impl IntoIterator for Signatures { + type Item = (PublicKey, crypto::Signature); + type IntoIter = as IntoIterator>::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +impl Extend for Signatures { + fn extend(&mut self, iter: T) + where + T: IntoIterator, + { + for Signature { key, sig } in iter { + self.insert(key, sig); + } + } +} + +impl Extend<(PublicKey, crypto::Signature)> for Signatures { + fn extend(&mut self, iter: T) + where + T: IntoIterator, + { + for (key, sig) in iter { + self.insert(key, sig); + } + } +} diff --git a/radicle-cob/src/signatures/error.rs b/radicle-cob/src/signatures/error.rs new file mode 100644 index 00000000..2804c132 --- /dev/null +++ b/radicle-cob/src/signatures/error.rs @@ -0,0 +1,27 @@ +// Copyright © 2019-2020 The Radicle Foundation +// +// This file is part of radicle-link, distributed under the GPLv3 with Radicle +// Linking Exception. For full terms see the included LICENSE file. + +use radicle_crypto::ssh::ExtendedSignatureError; +use thiserror::Error; + +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum Signature { + #[error("missing {0}")] + Missing(&'static str), + + #[error(transparent)] + Serde(#[from] serde::de::value::Error), +} + +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum Signatures { + #[error(transparent)] + ExtendedSignature(#[from] ExtendedSignatureError), + + #[error(transparent)] + Signature(#[from] Signature), +} diff --git a/radicle-cob/src/test.rs b/radicle-cob/src/test.rs new file mode 100644 index 00000000..6a5e11f8 --- /dev/null +++ b/radicle-cob/src/test.rs @@ -0,0 +1,5 @@ +pub mod identity; +pub use identity::{Name, Person, Project, RemoteProject}; + +pub mod storage; +pub use storage::Storage; diff --git a/radicle-cob/src/test/identity.rs b/radicle-cob/src/test/identity.rs new file mode 100644 index 00000000..6ce679bd --- /dev/null +++ b/radicle-cob/src/test/identity.rs @@ -0,0 +1,29 @@ +pub mod project; +pub use project::{Project, RemoteProject}; + +pub mod person; +pub use person::Person; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Urn { + pub name: Name, + pub remote: Option, +} + +impl Urn { + pub fn to_path(&self) -> String { + match &self.remote { + Some(remote) => format!("{}/{}", self.name.as_str(), remote.as_str()), + None => self.name.0.to_string(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Name(String); + +impl Name { + pub fn as_str(&self) -> &str { + &self.0 + } +} diff --git a/radicle-cob/src/test/identity/person.rs b/radicle-cob/src/test/identity/person.rs new file mode 100644 index 00000000..d1ab4bef --- /dev/null +++ b/radicle-cob/src/test/identity/person.rs @@ -0,0 +1,112 @@ +use git_ext::Oid; +use serde::{Deserialize, Serialize}; + +use crate::identity::Identity; +use crate::test::storage::{self, Storage}; + +use super::{Name, Urn}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Person { + pub payload: Payload, + pub content_id: Oid, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Payload { + name: Name, + key: crypto::PublicKey, +} + +impl Person { + pub fn new( + repo: &Storage, + name: &str, + key: crypto::PublicKey, + ) -> Result { + let repo = repo.as_raw(); + let refname = format!("refs/rad/identities/{}", name); + let payload = Payload { + name: Name(name.to_owned()), + key, + }; + let blob = serde_json::to_vec(&payload)?; + let oid = repo.blob(&blob)?; + let mut tree = repo.treebuilder(None)?; + tree.insert("identity", oid, git2::FileMode::Blob.into())?; + let oid = tree.write()?; + let tree = repo.find_tree(oid)?; + let signature = git2::Signature::now(name, name)?; + let content_id = repo + .commit( + Some(&refname), + &signature, + &signature, + "persisted identity", + &tree, + &[], + )? + .into(); + Ok(Self { + payload, + content_id, + }) + } + + pub fn key(&self) -> crypto::PublicKey { + self.payload.key + } + + pub fn name(&self) -> &Name { + &self.payload.name + } + + pub fn find_by_oid( + repo: &git2::Repository, + id: Oid, + ) -> Result, storage::error::Identity> { + match repo.find_commit(id.into()) { + Ok(commit) => from_commit(repo, commit), + Err(err) if err.code() == git2::ErrorCode::NotFound => Ok(None), + Err(err) => Err(err.into()), + } + } +} + +fn from_commit( + repo: &git2::Repository, + commit: git2::Commit, +) -> Result, storage::error::Identity> { + let tree = commit.tree()?; + let entry = tree + .get_name("identity") + .ok_or_else(|| storage::error::Identity::NotFound(tree.id().into()))?; + let blob = match entry.to_object(repo)?.into_blob() { + Ok(blob) => blob, + Err(other) => return Err(storage::error::Identity::NotBlob(other.kind())), + }; + let payload = serde_json::de::from_slice(blob.content())?; + Ok(Some(Person { + payload, + content_id: commit.id().into(), + })) +} + +impl Identity for Person { + type Identifier = Urn; + + fn is_delegate(&self, delegation: &crypto::PublicKey) -> bool { + self.key() == *delegation + } + + fn content_id(&self) -> Oid { + self.content_id + } + + fn identifier(&self) -> Self::Identifier { + Urn { + name: self.name().clone(), + remote: None, + } + } +} diff --git a/radicle-cob/src/test/identity/project.rs b/radicle-cob/src/test/identity/project.rs new file mode 100644 index 00000000..ded22c65 --- /dev/null +++ b/radicle-cob/src/test/identity/project.rs @@ -0,0 +1,143 @@ +use std::collections::BTreeSet; + +use git_ext::Oid; +use serde::{Deserialize, Serialize}; + +use crate::identity::Identity; +use crate::test; +use crate::test::storage::{self, Storage}; + +use super::{Name, Urn}; + +pub struct RemoteProject { + pub project: Project, + pub person: test::Person, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Project { + pub payload: Payload, + pub content_id: Oid, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Payload { + name: Name, + delegates: BTreeSet, +} + +impl Project { + pub fn new( + repo: &Storage, + name: &str, + delegate: crypto::PublicKey, + ) -> Result { + let repo = repo.as_raw(); + let refname = format!("refs/rad/identities/{}", name); + let payload = Payload { + name: Name(name.to_owned()), + delegates: Some(delegate).into_iter().collect(), + }; + let blob = serde_json::to_vec(&payload)?; + let oid = repo.blob(&blob)?; + let mut tree = repo.treebuilder(None)?; + tree.insert("identity", oid, git2::FileMode::Blob.into())?; + let oid = tree.write()?; + let tree = repo.find_tree(oid)?; + let signature = git2::Signature::now(name, name)?; + let content_id = repo + .commit( + Some(&refname), + &signature, + &signature, + "persisted identity", + &tree, + &[], + )? + .into(); + Ok(Self { + payload, + content_id, + }) + } + + pub fn delegates(&self) -> &BTreeSet { + &self.payload.delegates + } + + pub fn delegate_check(&self, person: &test::Person) -> bool { + self.payload.delegates.contains(&person.key()) + } + + pub fn name(&self) -> &Name { + &self.payload.name + } + + pub fn find_by_oid( + repo: &git2::Repository, + id: Oid, + ) -> Result, storage::error::Identity> { + match repo.find_commit(id.into()) { + Ok(commit) => from_commit(repo, commit), + Err(err) if err.code() == git2::ErrorCode::NotFound => Ok(None), + Err(err) => Err(err.into()), + } + } +} + +fn from_commit( + repo: &git2::Repository, + commit: git2::Commit, +) -> Result, storage::error::Identity> { + let tree = commit.tree()?; + let entry = tree + .get_name("identity") + .ok_or_else(|| storage::error::Identity::NotFound(tree.id().into()))?; + let blob = match entry.to_object(repo)?.into_blob() { + Ok(blob) => blob, + Err(other) => return Err(storage::error::Identity::NotBlob(other.kind())), + }; + let payload = serde_json::de::from_slice(blob.content())?; + Ok(Some(Project { + payload, + content_id: commit.id().into(), + })) +} + +impl Identity for Project { + type Identifier = Urn; + + fn is_delegate(&self, delegation: &crypto::PublicKey) -> bool { + self.delegates().contains(delegation) + } + + fn content_id(&self) -> Oid { + self.content_id + } + + fn identifier(&self) -> Self::Identifier { + Urn { + name: self.name().clone(), + remote: None, + } + } +} + +impl Identity for RemoteProject { + type Identifier = Urn; + + fn is_delegate(&self, delegation: &crypto::PublicKey) -> bool { + self.project.delegates().contains(delegation) + } + + fn content_id(&self) -> Oid { + self.project.content_id + } + + fn identifier(&self) -> Self::Identifier { + Urn { + name: self.project.name().clone(), + remote: Some(self.person.name().clone()), + } + } +} diff --git a/radicle-cob/src/test/storage.rs b/radicle-cob/src/test/storage.rs new file mode 100644 index 00000000..a5ac73e0 --- /dev/null +++ b/radicle-cob/src/test/storage.rs @@ -0,0 +1,199 @@ +use std::{collections::HashMap, convert::TryFrom as _}; + +use git_ref_format::RefString; +use tempfile::TempDir; + +use crate::{ + change, + object::{self, Commit, Reference}, + ObjectId, Store, +}; + +use super::identity::{RemoteProject, Urn}; + +pub mod error { + use thiserror::Error; + + #[derive(Debug, Error)] + pub enum Identity { + #[error(transparent)] + Json(#[from] serde_json::Error), + #[error(transparent)] + Git(#[from] git2::Error), + #[error("'identity' was not a blob, found '{0:?}'")] + NotBlob(Option), + #[error("could not find 'identity' in the tree '{0}'")] + NotFound(git_ext::Oid), + } + + #[derive(Debug, Error)] + pub enum Objects { + #[error(transparent)] + Git(#[from] git2::Error), + #[error(transparent)] + Format(#[from] git_ref_format::Error), + } +} + +pub struct Storage { + raw: git2::Repository, + _temp: TempDir, +} + +impl Storage { + pub fn new() -> Self { + let temp = tempfile::tempdir().unwrap(); + let raw = git2::Repository::init(temp.path()).unwrap(); + let mut config = raw.config().unwrap(); + config.set_str("user.name", "Terry Pratchett").unwrap(); + config + .set_str("user.email", "http://www.gnuterrypratchett.com") + .unwrap(); + Self { raw, _temp: temp } + } + + pub fn as_raw(&self) -> &git2::Repository { + &self.raw + } +} + +impl Store for Storage {} + +impl change::Storage for Storage { + type CreateError = ::CreateError; + type LoadError = ::LoadError; + + type ObjectId = ::ObjectId; + type Author = ::Author; + type Resource = ::Resource; + type Signatures = ::Signatures; + + fn create( + &self, + author: Option, + authority: Self::Resource, + signer: &Signer, + spec: change::Create, + ) -> Result< + change::store::Change, + Self::CreateError, + > + where + Signer: crypto::Signer, + { + self.as_raw().create(author, authority, signer, spec) + } + + fn load( + &self, + id: Self::ObjectId, + ) -> Result< + change::store::Change, + Self::LoadError, + > { + self.as_raw().load(id) + } +} + +impl object::Storage for Storage { + type ObjectsError = error::Objects; + type TypesError = error::Objects; + type UpdateError = git2::Error; + + type Identifier = Urn; + + fn objects( + &self, + identifier: &Self::Identifier, + typename: &crate::TypeName, + object_id: &ObjectId, + ) -> Result { + let name = format!( + "refs/rad/{}/cobs/{}/{}", + identifier.to_path(), + typename, + object_id + ); + let glob = format!( + "refs/rad/{}/*/cobs/{}/{}", + identifier.name.as_str(), + typename, + object_id + ); + let local = { + let r = self.raw.find_reference(&name)?; + Some(resolve_reference(r)?) + }; + let remotes = self + .raw + .references_glob(&glob)? + .map(|r| r.map_err(error::Objects::from).and_then(resolve_reference)) + .collect::, _>>()?; + Ok(object::Objects { local, remotes }) + } + + fn types( + &self, + identifier: &Self::Identifier, + typename: &crate::TypeName, + ) -> Result, Self::TypesError> { + let mut objects = HashMap::new(); + let prefix = format!("refs/rad/{}/cobs/{}", identifier.to_path(), typename); + for r in self.raw.references()? { + let r = r?; + let name = r.name().unwrap(); + let oid = r + .target() + .map(ObjectId::from) + .expect("BUG: the cob references should be direct"); + if name.starts_with(&prefix) { + objects.insert( + oid, + object::Objects { + local: Some(resolve_reference(r)?), + remotes: Vec::new(), + }, + ); + } + } + Ok(objects) + } + + fn update( + &self, + identifier: &Self::Identifier, + typename: &crate::TypeName, + object_id: &ObjectId, + change: &change::Change, + ) -> Result<(), Self::UpdateError> { + let name = format!( + "refs/rad/{}/cobs/{}/{}", + identifier.to_path(), + typename, + object_id + ); + let id = *change.id(); + self.raw.reference(&name, id.into(), true, "new change")?; + Ok(()) + } +} + +fn resolve_reference(r: git2::Reference) -> Result { + let commit = r.peel_to_commit()?; + let target = resolve_parents(commit)?; + Ok(Reference { + name: RefString::try_from(r.name().unwrap().to_owned())?, + target, + }) +} + +fn resolve_parents(commit: git2::Commit) -> Result { + let parents = commit + .parents() + .map(resolve_parents) + .collect::, _>>()?; + Ok(Commit { + id: commit.id().into(), + parents, + }) +} diff --git a/radicle-cob/src/tests.rs b/radicle-cob/src/tests.rs new file mode 100644 index 00000000..5d70512e --- /dev/null +++ b/radicle-cob/src/tests.rs @@ -0,0 +1,253 @@ +use std::ops::ControlFlow; + +use crypto::test::signer::MockSigner; +use quickcheck::Arbitrary; +use radicle_crypto::Signer; + +use crate::{ + create, get, history, identity::Identity, list, update, Create, ObjectId, TypeName, Update, +}; + +use super::test; + +#[test] +fn roundtrip() { + let storage = test::Storage::new(); + let signer = gen::(1); + let terry = test::Person::new(&storage, "terry", *signer.public_key()).unwrap(); + let proj = test::Project::new(&storage, "discworld", *signer.public_key()).unwrap(); + let proj = test::RemoteProject { + project: proj, + person: terry.clone(), + }; + let contents = history::Contents::Automerge(Vec::new()); + let typename = "xyz.rad.issue".parse::().unwrap(); + let cob = create( + &storage, + signer, + &proj, + Create { + author: Some(terry), + contents, + typename: typename.clone(), + message: "creating xyz.rad.issue".to_string(), + }, + ) + .unwrap(); + + let expected = get(&storage, &proj, &typename, cob.id()) + .unwrap() + .expect("BUG: cob was missing"); + + assert_eq!(cob, expected); +} + +#[test] +fn list_cobs() { + let storage = test::Storage::new(); + let signer = gen::(1); + let terry = test::Person::new(&storage, "terry", *signer.public_key()).unwrap(); + let proj = test::Project::new(&storage, "discworld", *signer.public_key()).unwrap(); + let proj = test::RemoteProject { + project: proj, + person: terry.clone(), + }; + let typename = "xyz.rad.issue".parse::().unwrap(); + let issue_1 = create( + &storage, + signer.clone(), + &proj, + Create { + author: Some(terry.clone()), + contents: history::Contents::Automerge(b"issue 1".to_vec()), + typename: typename.clone(), + message: "creating xyz.rad.issue".to_string(), + }, + ) + .unwrap(); + + let issue_2 = create( + &storage, + signer, + &proj, + Create { + author: Some(terry), + contents: history::Contents::Automerge(b"issue 2".to_vec()), + typename: typename.clone(), + message: "commenting xyz.rad.issue".to_string(), + }, + ) + .unwrap(); + + let mut expected = list(&storage, &proj, &typename).unwrap(); + expected.sort_by(|x, y| x.id().cmp(y.id())); + + let mut actual = vec![issue_1, issue_2]; + actual.sort_by(|x, y| x.id().cmp(y.id())); + + assert_eq!(actual, expected); +} + +#[test] +fn update_cob() { + let storage = test::Storage::new(); + let signer = gen::(1); + let terry = test::Person::new(&storage, "terry", *signer.public_key()).unwrap(); + let proj = test::Project::new(&storage, "discworld", *signer.public_key()).unwrap(); + let proj = test::RemoteProject { + project: proj, + person: terry.clone(), + }; + let contents = history::Contents::Automerge(Vec::new()); + let typename = "xyz.rad.issue".parse::().unwrap(); + let cob = create( + &storage, + signer.clone(), + &proj, + Create { + author: Some(terry.clone()), + contents, + typename: typename.clone(), + message: "creating xyz.rad.issue".to_string(), + }, + ) + .unwrap(); + + let not_expected = get(&storage, &proj, &typename, cob.id()) + .unwrap() + .expect("BUG: cob was missing"); + + let updated = update( + &storage, + signer, + &proj, + Update { + author: Some(terry), + changes: history::Contents::Automerge(b"issue 1".to_vec()), + object_id: *cob.id(), + typename: typename.clone(), + message: "commenting xyz.rad.issue".to_string(), + }, + ) + .unwrap(); + + let expected = get(&storage, &proj, &typename, updated.id()) + .unwrap() + .expect("BUG: cob was missing"); + + assert_ne!(updated, not_expected); + assert_eq!(updated, expected); +} + +#[test] +fn traverse_cobs() { + let storage = test::Storage::new(); + let neil_signer = gen::(2); + let neil = test::Person::new(&storage, "gaiman", *neil_signer.public_key()).unwrap(); + let terry_signer = gen::(1); + let terry = test::Person::new(&storage, "pratchett", *terry_signer.public_key()).unwrap(); + let proj = test::Project::new(&storage, "discworld", *terry_signer.public_key()).unwrap(); + let terry_proj = test::RemoteProject { + project: proj.clone(), + person: terry.clone(), + }; + let neil_proj = test::RemoteProject { + project: proj, + person: neil.clone(), + }; + let typename = "xyz.rad.issue".parse::().unwrap(); + let cob = create( + &storage, + terry_signer, + &terry_proj, + Create { + author: Some(terry), + contents: history::Contents::Automerge(b"issue 1".to_vec()), + typename: typename.clone(), + message: "creating xyz.rad.issue".to_string(), + }, + ) + .unwrap(); + copy_to( + storage.as_raw(), + &terry_proj, + &neil_proj, + &typename, + *cob.id(), + ) + .unwrap(); + + let updated = update( + &storage, + neil_signer, + &neil_proj, + Update { + author: Some(neil), + changes: history::Contents::Automerge(b"issue 2".to_vec()), + object_id: *cob.id(), + typename, + message: "commenting on xyz.rad.issue".to_string(), + }, + ) + .unwrap(); + + // traverse over the history and filter by changes that were only authorized by terry + let contents = updated.history().traverse(Vec::new(), |mut acc, entry| { + let author = match entry.author() { + Some(author) => test::Person::find_by_oid(storage.as_raw(), *author).unwrap(), + None => None, + }; + let project = test::Project::find_by_oid(storage.as_raw(), entry.resource()).unwrap(); + + if let (Some(author), Some(project)) = (author, project) { + if project.delegate_check(&author) { + acc.push(entry.contents().as_ref().to_vec()) + } + } + ControlFlow::Continue(acc) + }); + + assert_eq!(contents, vec![b"issue 1".to_vec()]); + + // traverse over the history and filter by changes that were only authorized by neil + let contents = updated.history().traverse(Vec::new(), |mut acc, entry| { + acc.push(entry.contents().as_ref().to_vec()); + ControlFlow::Continue(acc) + }); + + assert_eq!(contents, vec![b"issue 1".to_vec(), b"issue 2".to_vec()]); +} + +fn gen(size: usize) -> T { + let mut gen = quickcheck::Gen::new(size); + + T::arbitrary(&mut gen) +} + +fn copy_to( + repo: &git2::Repository, + from: &test::RemoteProject, + to: &test::RemoteProject, + typename: &TypeName, + object: ObjectId, +) -> Result<(), git2::Error> { + let original = { + let name = format!( + "refs/rad/{}/cobs/{}/{}", + from.identifier().to_path(), + typename, + object + ); + let r = repo.find_reference(&name)?; + r.target().unwrap() + }; + + let name = format!( + "refs/rad/{}/cobs/{}/{}", + to.identifier().to_path(), + typename, + object + ); + repo.reference(&name, original, false, "copying object reference")?; + Ok(()) +} diff --git a/radicle-cob/src/trailers.rs b/radicle-cob/src/trailers.rs new file mode 100644 index 00000000..1f249a2a --- /dev/null +++ b/radicle-cob/src/trailers.rs @@ -0,0 +1,116 @@ +// Copyright © 2019-2020 The Radicle Foundation +// +// This file is part of radicle-link, distributed under the GPLv3 with Radicle +// Linking Exception. For full terms see the included LICENSE file. + +mod author_commit { + super::oid_trailer! {AuthorCommitTrailer, "Rad-Author"} +} +mod resource_identity { + super::oid_trailer! {ResourceCommitTrailer, "Rad-Resource"} +} + +pub mod error { + pub use super::author_commit::Error as InvalidAuthorTrailer; + + pub use super::resource_identity::Error as InvalidResourceTrailer; +} + +pub use author_commit::AuthorCommitTrailer; +pub use resource_identity::ResourceCommitTrailer; + +/// A macro for generating boilerplate From and TryFrom impls for trailers which +/// have git object IDs as their values +#[macro_export] +macro_rules! oid_trailer { + ($typename:ident, $trailer:literal) => { + use git_trailers::{OwnedTrailer, Token, Trailer}; + use radicle_git_ext as ext; + + use std::convert::{TryFrom, TryInto}; + + #[derive(Debug)] + pub enum Error { + WrongToken, + NoTrailer, + NoValue, + InvalidOid, + } + + // We can't use `derive(thiserror::Error)` as we need to concat strings with + // $trailer and macros are not allowed in non-key-value attributes + impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::WrongToken => { + write!(f, concat!("found wrong token for ", $trailer, " trailer")) + } + Self::NoTrailer => write!(f, concat!("no ", $trailer)), + Self::NoValue => write!(f, concat!("no value for ", $trailer, " trailer")), + Self::InvalidOid => write!(f, "invalid git OID"), + } + } + } + + impl std::error::Error for Error {} + + pub struct $typename(git2::Oid); + + impl $typename { + pub fn oid(&self) -> git2::Oid { + self.0 + } + } + + impl From for $typename { + fn from(oid: git2::Oid) -> Self { + $typename(oid) + } + } + + impl From<$typename> for Trailer<'_> { + fn from(containing: $typename) -> Self { + Trailer { + token: Token::try_from($trailer).unwrap(), + values: vec![containing.0.to_string().into()], + } + } + } + + impl From<$typename> for OwnedTrailer { + fn from(containing: $typename) -> Self { + Trailer::from(containing).to_owned() + } + } + + impl TryFrom<&Trailer<'_>> for $typename { + type Error = Error; + + fn try_from(Trailer { values, token }: &Trailer<'_>) -> Result { + let val = values.first().ok_or(Error::NoValue)?; + let ext_oid = + radicle_git_ext::Oid::try_from(val.as_ref()).map_err(|_| Error::InvalidOid)?; + if Some(token) == Token::try_from($trailer).ok().as_ref() { + Ok($typename(ext_oid.into())) + } else { + Err(Error::WrongToken) + } + } + } + + impl TryFrom<&OwnedTrailer> for $typename { + type Error = Error; + + fn try_from(trailer: &OwnedTrailer) -> Result { + (&Trailer::from(trailer)).try_into() + } + } + + impl From for $typename { + fn from(oid: ext::Oid) -> Self { + $typename(oid.into()) + } + } + }; +} +pub(crate) use oid_trailer; diff --git a/radicle-cob/src/type_name.rs b/radicle-cob/src/type_name.rs new file mode 100644 index 00000000..5eea9a7c --- /dev/null +++ b/radicle-cob/src/type_name.rs @@ -0,0 +1,70 @@ +// Copyright © 2022 The Radicle Link Contributors +// +// This file is part of radicle-link, distributed under the GPLv3 with Radicle +// Linking Exception. For full terms see the included LICENSE file. + +use std::{fmt, str::FromStr}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// The typename of an object. Valid typenames MUST be sequences of +/// alphanumeric characters separated by a period. The name must start +/// and end with an alphanumeric character +/// +/// # Examples +/// +/// * `abc.def` +/// * `xyz.rad.issues` +/// * `xyz.rad.patches.releases` +#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct TypeName(String); + +impl fmt::Display for TypeName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.0.as_str()) + } +} + +#[derive(Error, Debug)] +#[error("the type name '{invalid}' is invalid")] +pub struct TypeNameParse { + invalid: String, +} + +impl FromStr for TypeName { + type Err = TypeNameParse; + + fn from_str(s: &str) -> Result { + let split = s.split('.'); + for component in split { + if component.is_empty() { + return Err(TypeNameParse { + invalid: s.to_string(), + }); + } + if !component.chars().all(char::is_alphanumeric) { + return Err(TypeNameParse { + invalid: s.to_string(), + }); + } + } + Ok(TypeName(s.to_string())) + } +} + +#[cfg(test)] +mod test { + use std::str::FromStr as _; + + use super::TypeName; + + #[test] + fn valid_typenames() { + assert!(TypeName::from_str("abc.def.ghi").is_ok()); + assert!(TypeName::from_str("abc.123.ghi").is_ok()); + assert!(TypeName::from_str("1bc.123.ghi").is_ok()); + assert!(TypeName::from_str(".abc.123.ghi").is_err()); + assert!(TypeName::from_str("abc.123.ghi.").is_err()); + } +}