radicle-cob: port collaborative objects

This is a minimal port of the collaborative objects code from
radicle-link[0].

A `CollaborativeObject` is made up of a `History` of `Change`s that
are expected to be stored in a `git` backend.

The library exposes a CRU series of functions `create`, `get`, `list`,
and `update`.

This implementation differs, to previous one, in that it does not
assume any kind of identity system -- its only assumption is that
identities have an identifier and can be addressed in the `git`
database. This means that it does not require an `author` to be passed
in and it only uses the `resource` (previously `authorizing_identity`)
to store its content-address in the `Change`.

It also foregoes any caching to simplify the port and not make any
early optimisation assumptions.

[0]: https://github.com/radicle-dev/radicle-link/tree/master/cob

Signed-off-by: Fintan Halpenny <fintan.halpenny@gmail.com>
This commit is contained in:
Fintan Halpenny 2022-10-17 14:57:11 +01:00
parent 58c26e1c9d
commit f3c4a53e1e
No known key found for this signature in database
GPG Key ID: 2552FB6F64066CB7
36 changed files with 3381 additions and 181 deletions

465
Cargo.lock generated
View File

@ -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"

View File

@ -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",

47
radicle-cob/Cargo.toml Normal file
View File

@ -0,0 +1,47 @@
[package]
name = "radicle-cob"
version = "0.1.0"
authors = [
"Alex Good <alex@memoryandthought.me>",
"Fintan Halpenny <fintan.halpenny@gmail.com>",
]
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"]

View File

@ -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;

View File

@ -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;

View File

@ -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<dyn std::error::Error + Send + Sync + 'static>),
#[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<Signer>(
&self,
author: Option<Self::Author>,
resource: Self::Resource,
signer: &Signer,
spec: store::Create<Self::ObjectId>,
) -> Result<Change, Self::CreateError>
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<Change, Self::LoadError> {
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<Item = &'a OwnedTrailer>,
) -> Result<(Option<Oid>, 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<store::Manifest, error::Load> {
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<entry::Contents, error::Load> {
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<O>(
repo: &git2::Repository,
author: Option<O>,
resource: O,
tips: Vec<O>,
message: String,
signature: Signature,
tree: git2::Tree,
) -> Result<Oid, error::Create>
where
O: AsRef<git2::Oid>,
{
let author = author.map(|author| *author.as_ref());
let resource = *resource.as_ref();
let mut parents = tips.iter().map(|o| *o.as_ref()).collect::<Vec<_>>();
parents.push(resource);
parents.extend(author);
let mut trailers: Vec<OwnedTrailer> =
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<git2::Oid, git2::Error> {
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()
}

View File

16
radicle-cob/src/change.rs Normal file
View File

@ -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<Oid, Oid, Oid, Signatures>;

View File

@ -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<Signer>(
&self,
author: Option<Self::Author>,
authority: Self::Resource,
signer: &Signer,
spec: Create<Self::ObjectId>,
) -> Result<
Change<Self::Author, Self::Resource, Self::ObjectId, Self::Signatures>,
Self::CreateError,
>
where
Signer: crypto::Signer;
#[allow(clippy::type_complexity)]
fn load(
&self,
id: Self::ObjectId,
) -> Result<
Change<Self::Author, Self::Resource, Self::ObjectId, Self::Signatures>,
Self::LoadError,
>;
}
pub struct Create<Id> {
pub typename: TypeName,
pub tips: Vec<Id>,
pub message: String,
pub contents: Contents,
}
#[derive(Clone, Debug)]
pub struct Change<Author, Resource, Id, Signatures> {
/// 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<Author>,
/// 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<Author, Resource, Id, S> fmt::Display for Change<Author, Resource, Id, S>
where
Id: fmt::Display,
{
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Change {{ id: {} }}", self.id)
}
}
impl<Author, Resource, Id, Signatures> Change<Author, Resource, Id, Signatures> {
pub fn id(&self) -> &Id {
&self.id
}
pub fn author(&self) -> &Option<Author> {
&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<A, R, Id> Change<A, R, Id, signatures::Signatures>
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,
}

View File

@ -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<Change, ()>,
}
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<Item = &'a object::Reference> + 'a,
typename: &TypeName,
oid: &ObjectId,
) -> Option<ChangeGraph>
where
S: change::Storage<ObjectId = Oid, Author = Oid, Resource = Oid, Signatures = Signatures>,
{
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<petgraph::graph::NodeIndex<u32>> = 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::<Vec<_>>();
(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<Oid> {
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<Oid, petgraph::graph::NodeIndex<u32>>,
graph: petgraph::Graph<Change, ()>,
}
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<Item = (object::Commit, Oid)> + '_ {
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<ChangeGraph> {
if self
.graph
.externals(petgraph::Direction::Incoming)
.next()
.is_some()
{
Some(ChangeGraph {
object_id,
graph: self.graph,
})
} else {
None
}
}
}

View File

@ -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<Item = (&'b Change, Vec<Oid>)>,
) -> 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<history::Entry, RejectionReason> {
// 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<Oid>,
}
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,
}

162
radicle-cob/src/history.rs Normal file
View File

@ -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<Entry, (), petgraph::Directed, u32>,
indices: HashMap<EntryId, petgraph::graph::NodeIndex<u32>>,
}
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: Id,
author: Option<Oid>,
resource: Oid,
contents: Contents,
) -> Self
where
Id: Into<EntryId>,
{
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<Id>(root: Id, entries: HashMap<EntryId, Entry>) -> Result<Self, CreateError>
where
Id: Into<EntryId>,
{
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<F, A>(&self, init: A, f: F) -> A
where
F: for<'r> FnMut(A, &'r Entry) -> ControlFlow<A, A>,
{
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<Oid> {
self.graph
.externals(petgraph::Direction::Outgoing)
.map(|n| {
let entry = &self.graph[n];
(*entry.id()).into()
})
.collect()
}
pub(crate) fn extend<Id>(
&mut self,
new_id: Id,
new_author: Option<Oid>,
new_resource: Oid,
new_contents: Contents,
) where
Id: Into<EntryId>,
{
let tips = self.tips();
let new_id = new_id.into();
let new_entry = Entry::new(
new_id,
new_author,
new_resource,
std::iter::empty::<git2::Oid>(),
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<Entry, (), petgraph::Directed, u32>,
indices: HashMap<EntryId, petgraph::graph::NodeIndex<u32>>,
}
fn create_petgraph<'a>(root: &'a EntryId, entries: &'a HashMap<EntryId, Entry>) -> NewGraph {
let mut graph = petgraph::Graph::new();
let mut indices = HashMap::<EntryId, petgraph::graph::NodeIndex<u32>>::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 }
}

View File

@ -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<u8>),
}
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<git2::Oid> for EntryId {
fn from(id: git2::Oid) -> Self {
Self(id.into())
}
}
impl From<Oid> for EntryId {
fn from(id: Oid) -> Self {
Self(id)
}
}
impl From<EntryId> 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<Oid>,
/// The content-address for the resource this entry lives under.
pub(super) resource: Oid,
/// The child entries for this entry.
pub(super) children: Vec<EntryId>,
/// The contents of this entry.
pub(super) contents: Contents,
}
impl Entry {
pub fn new<Id1, Id2, ChildIds>(
id: Id1,
author: Option<Oid>,
resource: Oid,
children: ChildIds,
contents: Contents,
) -> Self
where
Id1: Into<EntryId>,
Id2: Into<EntryId>,
ChildIds: IntoIterator<Item = Id2>,
{
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<Item = &EntryId> {
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<Oid> {
&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
}
}

View File

@ -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;
}

133
radicle-cob/src/lib.rs Normal file
View File

@ -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<Resource>
where
Resource: identity::Identity,
Self: object::Storage<Identifier = Resource::Identifier>
+ 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,
>,
{
}

85
radicle-cob/src/object.rs Normal file
View File

@ -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<Self, Self::Err> {
let oid = Oid::try_from(s.as_bytes())?;
Ok(ObjectId(oid))
}
}
impl From<Oid> for ObjectId {
fn from(oid: Oid) -> Self {
ObjectId(oid)
}
}
impl From<&Oid> for ObjectId {
fn from(oid: &Oid) -> Self {
(*oid).into()
}
}
impl From<git2::Oid> 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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_bytes(self.0.as_bytes())
}
}
impl<'de> Deserialize<'de> for ObjectId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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))
}
}

View File

@ -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<Oid> {
self.history.tips().into_iter().map(Oid::from).collect()
}
}

View File

@ -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<Author> {
/// The identity of the author for this object's first change.
pub author: Option<Author>,
/// 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<Author> Create<Author> {
fn create_spec(&self) -> change::Create<git_ext::Oid> {
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<S, Signer, Author, Resource>(
storage: &S,
signer: Signer,
resource: &Resource,
args: Create<Author>,
) -> Result<CollaborativeObject, error::Create>
where
S: Store<Resource>,
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(),
})
}

View File

@ -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<dyn std::error::Error + Send + Sync + 'static>,
},
#[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<dyn std::error::Error + Send + Sync + 'static>,
},
#[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<dyn std::error::Error + Send + Sync + 'static>,
},
#[error(transparent)]
Git(#[from] git2::Error),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("signer must belong to the author")]
SignerIsNotAuthor,
}

View File

@ -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<S, Resource>(
storage: &S,
resource: &Resource,
typename: &TypeName,
oid: &ObjectId,
) -> Result<Option<CollaborativeObject>, error::Retrieve>
where
S: Store<Resource>,
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()))
}

View File

@ -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<Oid>,
}
/// 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<S, Resource>(
storage: &S,
resource: &Resource,
typename: &TypeName,
oid: &ObjectId,
) -> Result<Option<ChangeGraphInfo>, error::Retrieve>
where
S: Store<Resource>,
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(),
}),
)
}

View File

@ -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<S, Resource>(
storage: &S,
resource: &Resource,
typename: &TypeName,
) -> Result<Vec<CollaborativeObject>, error::Retrieve>
where
S: Store<Resource>,
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)
}

View File

@ -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<Author> {
/// The identity of the author for this object's first change.
pub author: Option<Author>,
/// 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<S, Signer, Resource, Author>(
storage: &S,
signer: Signer,
resource: &Resource,
args: Update<Author>,
) -> Result<CollaborativeObject, error::Update>
where
S: Store<Resource>,
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)
}

View File

@ -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<Reference>,
/// The `remotes` are the entries for each remote peer's version
/// of the particular object.
pub remotes: Vec<Reference>,
}
impl Objects {
/// Return an iterator over the `local` and `remotes` of the given
/// [`Objects`].
pub fn iter(&self) -> impl Iterator<Item = &Reference> {
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<Commit>,
}
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<Objects, Self::ObjectsError>;
/// Get all references to objects of a given type within a particular
/// identity
fn types(
&self,
identifier: &Self::Identifier,
typename: &TypeName,
) -> Result<HashMap<ObjectId, Objects>, 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>;
}

View File

@ -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<Node> + 'a,
Node: 'a + GraphNode,
It: Iterator<Item = BN>,
F: for<'r> FnMut(O, &'r Node) -> std::ops::ControlFlow<O, O>,
{
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<NodeId> {
/// Changes which are directly rejected by the fold function
direct: BTreeSet<NodeId>,
/// A map from node IDs to the IDs of ancestor nodes which are
/// direct rejections
transitive: HashMap<NodeId, NodeId>,
}
impl<NodeId: Clone + Eq + Ord + std::hash::Hash> RejectedNodes<NodeId> {
fn new() -> RejectedNodes<NodeId> {
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());
}
}

7
radicle-cob/src/sign.rs Normal file
View File

@ -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};

View File

@ -0,0 +1,144 @@
// Copyright © 2019-2020 The Radicle Foundation <hello@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<Signature> for ExtendedSignature {
fn from(sig: Signature) -> Self {
Self::new(sig.key, sig.sig)
}
}
impl From<ExtendedSignature> 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<PublicKey, crypto::Signature>);
impl Deref for Signatures {
type Target = BTreeMap<PublicKey, crypto::Signature>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for Signatures {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl From<Signature> for Signatures {
fn from(Signature { key, sig }: Signature) -> Self {
let mut map = BTreeMap::new();
map.insert(key, sig);
map.into()
}
}
impl From<BTreeMap<PublicKey, crypto::Signature>> for Signatures {
fn from(map: BTreeMap<PublicKey, crypto::Signature>) -> Self {
Self(map)
}
}
impl From<Signatures> for BTreeMap<PublicKey, crypto::Signature> {
fn from(s: Signatures) -> Self {
s.0
}
}
impl TryFrom<&Commit> for Signatures {
type Error = error::Signatures;
fn try_from(value: &Commit) -> Result<Self, Self::Error> {
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::<Result<_, _>>()
}
}
impl FromIterator<(PublicKey, crypto::Signature)> for Signatures {
fn from_iter<T>(iter: T) -> Self
where
T: IntoIterator<Item = (PublicKey, crypto::Signature)>,
{
Self(BTreeMap::from_iter(iter))
}
}
impl IntoIterator for Signatures {
type Item = (PublicKey, crypto::Signature);
type IntoIter = <BTreeMap<PublicKey, crypto::Signature> as IntoIterator>::IntoIter;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}
impl Extend<Signature> for Signatures {
fn extend<T>(&mut self, iter: T)
where
T: IntoIterator<Item = Signature>,
{
for Signature { key, sig } in iter {
self.insert(key, sig);
}
}
}
impl Extend<(PublicKey, crypto::Signature)> for Signatures {
fn extend<T>(&mut self, iter: T)
where
T: IntoIterator<Item = (PublicKey, crypto::Signature)>,
{
for (key, sig) in iter {
self.insert(key, sig);
}
}
}

View File

@ -0,0 +1,27 @@
// Copyright © 2019-2020 The Radicle Foundation <hello@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),
}

5
radicle-cob/src/test.rs Normal file
View File

@ -0,0 +1,5 @@
pub mod identity;
pub use identity::{Name, Person, Project, RemoteProject};
pub mod storage;
pub use storage::Storage;

View File

@ -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<Name>,
}
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
}
}

View File

@ -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<Self, storage::error::Identity> {
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<Option<Person>, 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<Option<Person>, 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,
}
}
}

View File

@ -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<crypto::PublicKey>,
}
impl Project {
pub fn new(
repo: &Storage,
name: &str,
delegate: crypto::PublicKey,
) -> Result<Self, storage::error::Identity> {
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<crypto::PublicKey> {
&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<Option<Self>, 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<Option<Project>, 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()),
}
}
}

View File

@ -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<git2::ObjectType>),
#[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<RemoteProject> for Storage {}
impl change::Storage for Storage {
type CreateError = <git2::Repository as change::Storage>::CreateError;
type LoadError = <git2::Repository as change::Storage>::LoadError;
type ObjectId = <git2::Repository as change::Storage>::ObjectId;
type Author = <git2::Repository as change::Storage>::Author;
type Resource = <git2::Repository as change::Storage>::Resource;
type Signatures = <git2::Repository as change::Storage>::Signatures;
fn create<Signer>(
&self,
author: Option<Self::Author>,
authority: Self::Resource,
signer: &Signer,
spec: change::Create<Self::ObjectId>,
) -> Result<
change::store::Change<Self::Author, Self::Resource, Self::ObjectId, Self::Signatures>,
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::Author, Self::Resource, Self::ObjectId, Self::Signatures>,
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<object::Objects, Self::ObjectsError> {
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::<Result<Vec<_>, _>>()?;
Ok(object::Objects { local, remotes })
}
fn types(
&self,
identifier: &Self::Identifier,
typename: &crate::TypeName,
) -> Result<HashMap<ObjectId, object::Objects>, 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<Reference, error::Objects> {
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<Commit, git2::Error> {
let parents = commit
.parents()
.map(resolve_parents)
.collect::<Result<Vec<_>, _>>()?;
Ok(Commit {
id: commit.id().into(),
parents,
})
}

253
radicle-cob/src/tests.rs Normal file
View File

@ -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::<MockSigner>(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::<TypeName>().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::<MockSigner>(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::<TypeName>().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::<MockSigner>(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::<TypeName>().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::<MockSigner>(2);
let neil = test::Person::new(&storage, "gaiman", *neil_signer.public_key()).unwrap();
let terry_signer = gen::<MockSigner>(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::<TypeName>().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<T: Arbitrary>(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(())
}

116
radicle-cob/src/trailers.rs Normal file
View File

@ -0,0 +1,116 @@
// Copyright © 2019-2020 The Radicle Foundation <hello@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<git2::Oid> 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<Self, Self::Error> {
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<Self, Self::Error> {
(&Trailer::from(trailer)).try_into()
}
}
impl From<ext::Oid> for $typename {
fn from(oid: ext::Oid) -> Self {
$typename(oid.into())
}
}
};
}
pub(crate) use oid_trailer;

View File

@ -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<Self, Self::Err> {
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());
}
}