Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
radicle-cob: port collaborative objects
Fintan Halpenny committed 3 years ago
commit f3c4a53e1eb50cd9f6fbf8eb82778bde397ee92e
parent 58c26e1c9d2f69bb0176e3a008829c88ed52cf87
36 files changed +3380 -180
modified Cargo.lock
@@ -30,27 +30,15 @@ dependencies = [

[[package]]
name = "anyhow"
-
version = "1.0.65"
+
version = "1.0.66"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "98161a4e3e2184da77bb14f02184cdd111e83bbbcc9979dfee3c44b9a85f5602"
-

-
[[package]]
-
name = "arrayref"
-
version = "0.3.6"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "a4c527152e37cf757a3f78aae5a06fbeefdb07ccc535c980a3208ee3060dd544"
-

-
[[package]]
-
name = "arrayvec"
-
version = "0.5.2"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b"
+
checksum = "216261ddc8289130e551ddcd5ce8a064710c0d064a4d2895c67151c92b5443f6"

[[package]]
name = "async-trait"
-
version = "0.1.57"
+
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "76464446b8bc32758d7e88ee1a804d9914cd9b1cb264c029899680b0be29826f"
+
checksum = "1e805d94e6b5001b651426cf4cd446b1ab5f319d27bab5c644f61de0a804360c"
dependencies = [
 "proc-macro2",
 "quote",
@@ -76,9 +64,9 @@ checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"

[[package]]
name = "axum"
-
version = "0.5.16"
+
version = "0.5.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "c9e3356844c4d6a6d6467b8da2cffb4a2820be256f50a3a386c9d152bab31043"
+
checksum = "acee9fd5073ab6b045a275b3e709c163dd36c90685219cb21804a147b58dba43"
dependencies = [
 "async-trait",
 "axum-core",
@@ -105,9 +93,9 @@ dependencies = [

[[package]]
name = "axum-core"
-
version = "0.2.8"
+
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "d9f0c0a60006f2a293d82d571f635042a72edf927539b7685bd62d361963839b"
+
checksum = "37e5939e02c56fecd5c017c37df4238c0a839fa76b7f97acdd7efb804fd181cc"
dependencies = [
 "async-trait",
 "bytes",
@@ -148,9 +136,9 @@ checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce"

[[package]]
name = "base64"
-
version = "0.13.0"
+
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd"
+
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"

[[package]]
name = "base64ct"
@@ -176,34 +164,11 @@ 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",
]

@@ -217,12 +182,6 @@ dependencies = [
]

[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -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",
]
@@ -300,6 +259,16 @@ dependencies = [
]

[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -317,12 +286,6 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -416,6 +379,50 @@ dependencies = [
]

[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -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]]
@@ -557,6 +564,12 @@ dependencies = [
]

[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -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,9 +656,9 @@ 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",
@@ -653,6 +666,17 @@ dependencies = [
]

[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -685,6 +709,16 @@ dependencies = [
]

[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -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,18 +851,29 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -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"
@@ -891,12 +936,6 @@ dependencies = [
]

[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -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"
@@ -948,6 +987,15 @@ dependencies = [
]

[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -984,6 +1032,12 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -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",
@@ -1016,21 +1070,6 @@ dependencies = [
]

[[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"
source = "git+https://github.com/cloudhead/nakamoto?rev=90cc3eac67aa5cfd5f42cf7cb1e2b155af3214fb#90cc3eac67aa5cfd5f42cf7cb1e2b155af3214fb"
@@ -1055,6 +1094,16 @@ dependencies = [
]

[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -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"
@@ -1216,6 +1265,16 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -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",
]
@@ -1384,6 +1443,26 @@ dependencies = [
]

[[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"
dependencies = [
@@ -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]]
@@ -1662,6 +1740,12 @@ dependencies = [
]

[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -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",
@@ -1720,19 +1804,6 @@ dependencies = [
]

[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1757,18 +1828,6 @@ dependencies = [
]

[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -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",
@@ -1946,6 +2005,15 @@ dependencies = [
]

[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -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"
@@ -2211,16 +2297,16 @@ dependencies = [
]

[[package]]
-
name = "unicode-xid"
-
version = "0.2.4"
+
name = "unicode-width"
+
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c"
+
checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b"

[[package]]
-
name = "unsigned-varint"
-
version = "0.5.1"
+
name = "unicode-xid"
+
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "f7fdeedbf205afadfe39ae559b75c3240f24e257d0ca27e85f85cb82aa19ac35"
+
checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c"

[[package]]
name = "url"
@@ -2350,6 +2436,15 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -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_gnullvm"
+
version = "0.42.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "41d2aa71f6f0cbe00ae5167d90ef3cfe66527d6f613ca78ac8024c3ccab9a19e"
+

+
[[package]]
name = "windows_aarch64_msvc"
-
version = "0.36.1"
+
version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47"
+
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 = "bf7b1b21b5362cbc318f686150e5bcea75ecedc74dd157d874d754a2ca44b0ed"
+

+
[[package]]
+
name = "windows_x86_64_gnullvm"
+
version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1"
+
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"
modified Cargo.toml
@@ -1,12 +1,13 @@
[workspace]
members = [
  "radicle",
+
  "radicle-cob",
  "radicle-crypto",
+
  "radicle-httpd",
  "radicle-node",
-
  "radicle-tools",
-
  "radicle-ssh",
  "radicle-remote-helper",
-
  "radicle-httpd"
+
  "radicle-ssh",
+
  "radicle-tools",
]
default-members = [
  "radicle",
added radicle-cob/Cargo.toml
@@ -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"]
added radicle-cob/src/backend.rs
@@ -0,0 +1,6 @@
+
// Copyright © 2022 The Radicle Link Contributors
+
//
+
// This file is part of radicle-link, distributed under the GPLv3 with Radicle
+
// Linking Exception. For full terms see the included LICENSE file.
+

+
pub mod git;
added radicle-cob/src/backend/git.rs
@@ -0,0 +1,6 @@
+
// Copyright © 2022 The Radicle Link Contributors
+
//
+
// This file is part of radicle-link, distributed under the GPLv3 with Radicle
+
// Linking Exception. For full terms see the included LICENSE file.
+

+
pub mod change;
added radicle-cob/src/backend/git/change.rs
@@ -0,0 +1,284 @@
+
// Copyright © 2022 The Radicle Link Contributors
+
//
+
// This file is part of radicle-link, distributed under the GPLv3 with Radicle
+
// Linking Exception. For full terms see the included LICENSE file.
+

+
use std::convert::TryFrom;
+

+
use git_commit::{self as commit, Commit};
+
use git_ext::Oid;
+
use git_trailers::OwnedTrailer;
+

+
use crate::{
+
    change::{self, store, Change},
+
    history::entry,
+
    signatures::{Signature, Signatures},
+
    trailers, HistoryType,
+
};
+

+
const MANIFEST_BLOB_NAME: &str = "manifest";
+
const CHANGE_BLOB_NAME: &str = "change";
+

+
pub mod error {
+
    use std::str::Utf8Error;
+
    use std::string::FromUtf8Error;
+

+
    use git_ext::Oid;
+
    use git_trailers::Error as TrailerError;
+
    use thiserror::Error;
+

+
    use crate::signatures::error::Signatures;
+
    use crate::trailers;
+

+
    #[derive(Debug, Error)]
+
    pub enum Create {
+
        #[error(transparent)]
+
        FromUtf8(#[from] FromUtf8Error),
+
        #[error(transparent)]
+
        Git(#[from] git2::Error),
+
        #[error(transparent)]
+
        Signer(#[from] Box<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()
+
}
added radicle-cob/src/backend/git/objects.rs
added radicle-cob/src/change.rs
@@ -0,0 +1,16 @@
+
// Copyright © 2021 The Radicle Link Contributors
+
//
+
// This file is part of radicle-link, distributed under the GPLv3 with Radicle
+
// Linking Exception. For full terms see the included LICENSE file.
+

+
use git_ext::Oid;
+

+
pub mod store;
+
pub use store::{Create, Storage};
+

+
use crate::signatures::Signatures;
+

+
/// A single change in the change graph. The layout of changes in the repository
+
/// is specified in the RFC (docs/rfc/0662-collaborative-objects.adoc)
+
/// under "Change Commits".
+
pub type Change = store::Change<Oid, Oid, Oid, Signatures>;
added radicle-cob/src/change/store.rs
@@ -0,0 +1,125 @@
+
// Copyright © 2022 The Radicle Link Contributors
+
//
+
// This file is part of radicle-link, distributed under the GPLv3 with Radicle
+
// Linking Exception. For full terms see the included LICENSE file.
+

+
use std::{error::Error, fmt};
+

+
use serde::{Deserialize, Serialize};
+

+
use crate::{
+
    history::{Contents, HistoryType},
+
    signatures, TypeName,
+
};
+

+
pub trait Storage {
+
    type CreateError: Error + Send + Sync + 'static;
+
    type LoadError: Error + Send + Sync + 'static;
+

+
    type ObjectId;
+
    type Author;
+
    type Resource;
+
    type Signatures;
+

+
    #[allow(clippy::type_complexity)]
+
    fn create<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,
+
}
added radicle-cob/src/change_graph.rs
@@ -0,0 +1,228 @@
+
// Copyright © 2021 The Radicle Link Contributors
+
//
+
// This file is part of radicle-link, distributed under the GPLv3 with Radicle
+
// Linking Exception. For full terms see the included LICENSE file.
+

+
use std::{
+
    collections::{hash_map::Entry, BTreeSet, HashMap},
+
    convert::TryInto,
+
};
+

+
use git_ext::Oid;
+
use petgraph::{
+
    visit::{EdgeRef, Topo, Walker},
+
    EdgeDirection,
+
};
+

+
use crate::{
+
    change, object, signatures::Signatures, Change, CollaborativeObject, ObjectId, TypeName,
+
};
+

+
mod evaluation;
+
use evaluation::evaluate;
+

+
/// The graph of changes for a particular collaborative object
+
pub(super) struct ChangeGraph {
+
    object_id: ObjectId,
+
    graph: petgraph::Graph<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
+
        }
+
    }
+
}
added radicle-cob/src/change_graph/evaluation.rs
@@ -0,0 +1,82 @@
+
// Copyright © 2021 The Radicle Link Contributors
+
//
+
// This file is part of radicle-link, distributed under the GPLv3 with Radicle
+
// Linking Exception. For full terms see the included LICENSE file.
+

+
use std::{collections::HashMap, ops::ControlFlow};
+

+
use git_ext::Oid;
+

+
use crate::{change::Change, history, pruning_fold};
+

+
/// # Panics
+
///
+
/// If the change corresponding to the root OID is not in `items`
+
pub fn evaluate<'b>(
+
    root: Oid,
+
    items: impl Iterator<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,
+
}
added radicle-cob/src/history.rs
@@ -0,0 +1,162 @@
+
// Copyright © 2021 The Radicle Link Contributors
+
//
+
// This file is part of radicle-link, distributed under the GPLv3 with Radicle
+
// Linking Exception. For full terms see the included LICENSE file.
+

+
use std::{
+
    collections::{BTreeSet, HashMap},
+
    ops::ControlFlow,
+
};
+

+
use git_ext::Oid;
+
use petgraph::visit::Walker as _;
+

+
use crate::pruning_fold;
+

+
pub mod entry;
+
pub use entry::{Contents, Entry, EntryId};
+

+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
+
pub enum HistoryType {
+
    Automerge,
+
}
+

+
/// The DAG of changes making up the history of a collaborative object.
+
///
+
/// The `Author` represents the content address for the author of the change entry.
+
#[derive(Clone, Debug)]
+
pub struct History {
+
    graph: petgraph::Graph<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 }
+
}
added radicle-cob/src/history/entry.rs
@@ -0,0 +1,133 @@
+
// Copyright © 2021 The Radicle Link Contributors
+
//
+
// This file is part of radicle-link, distributed under the GPLv3 with Radicle
+
// Linking Exception. For full terms see the included LICENSE file.
+

+
use git_ext::Oid;
+

+
use crate::pruning_fold;
+

+
use super::HistoryType;
+

+
#[derive(Clone, Debug, PartialEq, Hash, Eq)]
+
pub enum Contents {
+
    Automerge(Vec<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
+
    }
+
}
added radicle-cob/src/identity.rs
@@ -0,0 +1,29 @@
+
// Copyright © 2022 The Radicle Link Contributors
+
//
+
// This file is part of radicle-link, distributed under the GPLv3 with Radicle
+
// Linking Exception. For full terms see the included LICENSE file.
+

+
use git_ext::Oid;
+

+
/// An [`Identity`] represents a content addressed identity
+
/// (i.e. expected to be stored in a git backend).
+
///
+
/// It should have:
+
///   * A delegate system
+
///   * A content addressable identifier
+
///   * A unique, stable identifier
+
pub trait Identity {
+
    type Identifier;
+

+
    /// Confirm that the given [`crypto::PublicKey`] is a delegate for
+
    /// the identity.
+
    fn is_delegate(&self, delegation: &crypto::PublicKey) -> bool;
+

+
    /// Provide the content address for the given identity. This is
+
    /// expected to be the latest address for the identity at the time
+
    /// of use.
+
    fn content_id(&self) -> Oid;
+

+
    /// The unique, stable identifier for the identity.
+
    fn identifier(&self) -> Self::Identifier;
+
}
added radicle-cob/src/lib.rs
@@ -0,0 +1,133 @@
+
// Copyright © 2021 The Radicle Link Contributors
+
//
+
// This file is part of radicle-link, distributed under the GPLv3 with Radicle
+
// Linking Exception. For full terms see the included LICENSE file.
+

+
//! # Collaborative Objects
+
//!
+
//! Collaborative objects are graphs of CRDTs. The current CRDTs that
+
//! is intended to be used are specifically [automerge] CRDTs.
+
//!
+
//! The initial design is proposed at [RFC-0662], and this
+
//! implementation keeps to most of its design principle.
+
//!
+
//! ## Basic Types
+
//!
+
//! The basic types that are found in `radicle-cob` are:
+
//!   * [`CollaborativeObject`] -- the computed object itself.
+
//!   * [`ObjectId`] -- the content-address for a single collaborative
+
//!   object.
+
//!   * [`TypeName`] -- the name for a collection of collaborative objects.
+
//!   * [`History`] -- the traversable history of the changes made to
+
//!   a single collaborative object.
+
//!
+
//! ## CRU Interface (No Delete)
+
//!
+
//! The main entry for manipulating [`CollaborativeObject`]s is by
+
//! using the CRU like functions:
+
//!   * [`create`]
+
//!   * [`get`]
+
//!   * [`list`]
+
//!   * [`update`]
+
//!
+
//! ## Storage
+
//!
+
//! The storing of collaborative objects is based on a git
+
//! backend. The previously mentioned functions all accept a [`Store`]
+
//! as parameter. The `Store` itself is an accumulation of different
+
//! storage capabilities:
+
//!   * [`object::Storage`]
+
//!   * [`change::Storage`] -- **Note**: there is already an
+
//!   implementation for this for [`git2::Repository`] for convenience.
+
//!
+
//! The `Store` also takes a generic parameter to indicate the type of
+
//! resource the collaborative object is living inside, for example a
+
//! software project. This `Resource` must also implement
+
//! [`identity::Identity`] to allow the internal logic to reference
+
//! the resource's content-address in `git` as well as the stable
+
//! identifier used for the resource.
+
//!
+
//! ## History Traversal
+
//!
+
//! The [`History`] of a [`CollaborativeObject`] -- accessed via
+
//! [`CollaborativeObject::history`] -- has a method
+
//! [`History::traverse`] which provides a way of inspecting each
+
//! [`Entry`] and building up a final value.
+
//!
+
//! This mechanism would be used in tandem with [automerge] to load an
+
//! automerge document and deserialize into an application defined
+
//! object.
+
//!
+
//! This traversal is also the point at which the [`Entry::author`]
+
//! and [`Entry::resource`] can be retrieved to apply any kind of
+
//! filtering logic. For example, a specific `author`'s change may be
+
//! egregious, spouting terrible libel about Radicle. It is at this
+
//! point that the `author`'s change can be filtered out from the
+
//! final product of the traversal.
+
//!
+
//! [automerge]: https://automerge.org
+
//! [RFC-0662]: https://github.com/radicle-dev/radicle-link/blob/master/docs/rfc/0662-collaborative-objects.adoc
+

+
extern crate radicle_crypto as crypto;
+
extern crate radicle_git_ext as git_ext;
+

+
mod backend;
+
pub use backend::git;
+

+
mod change_graph;
+
mod trailers;
+

+
pub mod change;
+
pub use change::Change;
+

+
pub mod identity;
+

+
pub mod history;
+
pub use history::{Contents, Entry, History, HistoryType};
+

+
mod pruning_fold;
+

+
pub mod signatures;
+
use signatures::Signatures;
+

+
pub mod type_name;
+
pub use type_name::TypeName;
+

+
pub mod object;
+
pub use object::{create, get, info, list, update, CollaborativeObject, Create, ObjectId, Update};
+

+
#[cfg(test)]
+
mod test;
+

+
#[cfg(test)]
+
mod tests;
+

+
/// The `Store` is an aggregation of the different types of storage
+
/// traits required for editing [`CollaborativeObject`]s.
+
///
+
/// The backing store being used is expected to be a `git` backend.
+
///
+
/// To get started using this trait, you must implement the following
+
/// for the specific `git` storage:
+
///
+
///   * [`object::Storage`]
+
///   * [`identity::Identity`] for `Resource`
+
///
+
/// **Note**: [`change::Storage`] is already implemented for
+
/// [`git2::Repository`]. It is expected that the underlying storage
+
/// for `object::Storage` will also be `git2::Repository`, but if not
+
/// please open an issue to change the definition of `Store` :)
+
pub trait Store<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,
+
        >,
+
{
+
}
added radicle-cob/src/object.rs
@@ -0,0 +1,85 @@
+
// Copyright © 2022 The Radicle Link Contributors
+
//
+
// This file is part of radicle-link, distributed under the GPLv3 with Radicle
+
// Linking Exception. For full terms see the included LICENSE file.
+

+
use std::{convert::TryFrom as _, fmt, str::FromStr};
+

+
use git_ext::Oid;
+
use serde::{Deserialize, Serialize};
+
use thiserror::Error;
+

+
pub mod collaboration;
+
pub use collaboration::{create, get, info, list, update, CollaborativeObject, Create, Update};
+

+
pub mod storage;
+
pub use storage::{Commit, Objects, Reference, Storage};
+

+
#[derive(Debug, Error)]
+
pub enum ParseObjectId {
+
    #[error(transparent)]
+
    Git(#[from] git2::Error),
+
}
+

+
/// The id of an object
+
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+
pub struct ObjectId(Oid);
+

+
impl FromStr for ObjectId {
+
    type Err = ParseObjectId;
+

+
    fn from_str(s: &str) -> Result<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))
+
    }
+
}
added radicle-cob/src/object/collaboration.rs
@@ -0,0 +1,55 @@
+
// Copyright © 2022 The Radicle Link Contributors
+
//
+
// This file is part of radicle-link, distributed under the GPLv3 with Radicle
+
// Linking Exception. For full terms see the included LICENSE file.
+

+
use std::collections::BTreeSet;
+

+
use git_ext::Oid;
+

+
use crate::{change, identity::Identity, Contents, History, ObjectId, TypeName};
+

+
pub mod error;
+

+
mod create;
+
pub use create::{create, Create};
+

+
mod get;
+
pub use get::get;
+

+
pub mod info;
+

+
mod list;
+
pub use list::list;
+

+
mod update;
+
pub use update::{update, Update};
+

+
/// A collaborative object
+
#[derive(Debug, Clone, PartialEq, Eq)]
+
pub struct CollaborativeObject {
+
    /// The typename of this object
+
    pub(crate) typename: TypeName,
+
    /// The CRDT history we know about for this object
+
    pub(crate) history: History,
+
    /// The id of the object
+
    pub(crate) id: ObjectId,
+
}
+

+
impl CollaborativeObject {
+
    pub fn history(&self) -> &History {
+
        &self.history
+
    }
+

+
    pub fn id(&self) -> &ObjectId {
+
        &self.id
+
    }
+

+
    pub fn typename(&self) -> &TypeName {
+
        &self.typename
+
    }
+

+
    fn tips(&self) -> BTreeSet<Oid> {
+
        self.history.tips().into_iter().map(Oid::from).collect()
+
    }
+
}
added radicle-cob/src/object/collaboration/create.rs
@@ -0,0 +1,99 @@
+
// Copyright © 2022 The Radicle Link Contributors
+
//
+
// This file is part of radicle-link, distributed under the GPLv3 with Radicle
+
// Linking Exception. For full terms see the included LICENSE file.
+

+
use crate::Store;
+

+
use super::*;
+

+
/// The metadata required for creating a new [`CollaborativeObject`].
+
pub struct Create<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(),
+
    })
+
}
added radicle-cob/src/object/collaboration/error.rs
@@ -0,0 +1,57 @@
+
// Copyright © 2022 The Radicle Link Contributors
+
//
+
// This file is part of radicle-link, distributed under the GPLv3 with Radicle
+
// Linking Exception. For full terms see the included LICENSE file.
+

+
use thiserror::Error;
+

+
use crate::git;
+

+
#[derive(Debug, Error)]
+
pub enum Create {
+
    #[error("Invalid automerge history")]
+
    InvalidAutomergeHistory,
+
    #[error(transparent)]
+
    CreateChange(#[from] git::change::error::Create),
+
    #[error("failed to updated references for during object creation")]
+
    Refs {
+
        #[source]
+
        err: Box<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,
+
}
added radicle-cob/src/object/collaboration/get.rs
@@ -0,0 +1,35 @@
+
// Copyright © 2022 The Radicle Link Contributors
+
//
+
// This file is part of radicle-link, distributed under the GPLv3 with Radicle
+
// Linking Exception. For full terms see the included LICENSE file.
+

+
use crate::{change_graph::ChangeGraph, identity, CollaborativeObject, ObjectId, Store, TypeName};
+

+
use super::error;
+

+
/// Get a [`CollaborativeObject`], if it exists.
+
///
+
/// The `storage` is the backing storage for storing
+
/// [`crate::Change`]s at content-addressable locations. Please see
+
/// [`Store`] for further information.
+
///
+
/// The `resource` is the parent of this object, for example a
+
/// software project.
+
///
+
/// The `typename` is the type of object to be found, while the `oid`
+
/// is the identifier for the particular object under that type.
+
pub fn get<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()))
+
}
added radicle-cob/src/object/collaboration/info.rs
@@ -0,0 +1,67 @@
+
// Copyright © 2022 The Radicle Link Contributors
+
//
+
// This file is part of radicle-link, distributed under the GPLv3 with Radicle
+
// Linking Exception. For full terms see the included LICENSE file.
+

+
//! [`ChangeGraphInfo`] provides a useful debugging structure for
+
//! represnting a single [`crate::CollaborativeObject`]'s underlying
+
//! change graph. This includes a [`ChangeGraphInfo::dotviz`] for
+
//! describing the graph via [graphviz].
+
//!
+
//! [graphviz]: https://graphviz.org/
+

+
use std::collections::BTreeSet;
+

+
use git_ext::Oid;
+

+
use crate::{change_graph::ChangeGraph, identity::Identity, ObjectId, Store, TypeName};
+

+
use super::error;
+

+
/// Additional information about the change graph of an object
+
pub struct ChangeGraphInfo {
+
    /// The ID of the object
+
    pub object_id: ObjectId,
+
    /// A graphviz description of the changegraph of the object
+
    pub dotviz: String,
+
    /// The number of nodes in the change graph of the object
+
    pub number_of_nodes: u64,
+
    /// The "tips" of the change graph, i.e the object IDs pointed to by
+
    /// references to the object
+
    pub tips: BTreeSet<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(),
+
        }),
+
    )
+
}
added radicle-cob/src/object/collaboration/list.rs
@@ -0,0 +1,50 @@
+
// Copyright © 2022 The Radicle Link Contributors
+
//
+
// This file is part of radicle-link, distributed under the GPLv3 with Radicle
+
// Linking Exception. For full terms see the included LICENSE file.
+

+
use crate::{change_graph::ChangeGraph, identity::Identity, CollaborativeObject, Store, TypeName};
+

+
use super::error;
+

+
/// List a set of [`CollaborativeObject`].
+
///
+
/// The `storage` is the backing storage for storing
+
/// [`crate::Change`]s at content-addressable locations. Please see
+
/// [`Store`] for further information.
+
///
+
/// The `resource` is the parent of this object, for example a
+
/// software project.
+
///
+
/// The `typename` is the type of objects to listed.
+
pub fn list<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)
+
}
added radicle-cob/src/object/collaboration/update.rs
@@ -0,0 +1,101 @@
+
// Copyright © 2022 The Radicle Link Contributors
+
//
+
// This file is part of radicle-link, distributed under the GPLv3 with Radicle
+
// Linking Exception. For full terms see the included LICENSE file.
+

+
use crate::{
+
    change, change_graph::ChangeGraph, identity::Identity, CollaborativeObject, Contents, ObjectId,
+
    Store, TypeName,
+
};
+

+
use super::error;
+

+
/// The data required to update an object
+
pub struct Update<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)
+
}
added radicle-cob/src/object/storage.rs
@@ -0,0 +1,85 @@
+
// Copyright © 2021 The Radicle Link Contributors
+
//
+
// This file is part of radicle-link, distributed under the GPLv3 with Radicle
+
// Linking Exception. For full terms see the included LICENSE file.
+

+
use std::{collections::HashMap, error::Error};
+

+
use git_ext::Oid;
+
use git_ref_format::RefString;
+

+
use crate::change::Change;
+
use crate::{ObjectId, TypeName};
+

+
/// The [`Reference`]s that refer to the commits that make up a
+
/// [`crate::CollaborativeObject`].
+
#[derive(Clone, Debug)]
+
pub struct Objects {
+
    /// If the local peer has a [`Reference`] for this particular
+
    /// object, then `local` should be set.
+
    pub local: Option<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>;
+
}
added radicle-cob/src/pruning_fold.rs
@@ -0,0 +1,100 @@
+
// Copyright © 2021 The Radicle Link Contributors
+
//
+
// This file is part of radicle-link, distributed under the GPLv3 with Radicle
+
// Linking Exception. For full terms see the included LICENSE file.
+

+
use std::{
+
    borrow::Borrow,
+
    collections::{BTreeSet, HashMap},
+
    ops::ControlFlow,
+
};
+

+
pub(crate) trait GraphNode {
+
    type Id: Clone + Eq + Ord + std::hash::Hash + std::fmt::Debug;
+

+
    fn id(&self) -> &Self::Id;
+
    fn child_ids(&self) -> &[Self::Id];
+
}
+

+
/// Fold a topological sort of a directed acyclic graph, pruning some branches.
+
///
+
/// `items` must be an iterator over the nodes of the graph in topological
+
/// order. Assuming this is the case `fold` will only be called with nodes whose
+
/// ancestors have already been evaluated. Returning `ControlFlow::Break(..)`
+
/// from `fold` will omit evaluation of the current node and consequently omit
+
/// processing of any nodes who have the current node as an ancestor.
+
pub(crate) fn pruning_fold<'a, BN, Node, It, F, O>(init: O, items: It, mut f: F) -> O
+
where
+
    BN: Borrow<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());
+
    }
+
}
added radicle-cob/src/sign.rs
@@ -0,0 +1,7 @@
+
// Copyright © 2022 The Radicle Link Contributors
+
//
+
// This file is part of radicle-link, distributed under the GPLv3 with Radicle
+
// Linking Exception. For full terms see the included LICENSE file.
+

+
pub mod signatures;
+
pub use signatures::{Signature, Signatures};
added radicle-cob/src/signatures.rs
@@ -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);
+
        }
+
    }
+
}
added radicle-cob/src/signatures/error.rs
@@ -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),
+
}
added radicle-cob/src/test.rs
@@ -0,0 +1,5 @@
+
pub mod identity;
+
pub use identity::{Name, Person, Project, RemoteProject};
+

+
pub mod storage;
+
pub use storage::Storage;
added radicle-cob/src/test/identity.rs
@@ -0,0 +1,29 @@
+
pub mod project;
+
pub use project::{Project, RemoteProject};
+

+
pub mod person;
+
pub use person::Person;
+

+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct Urn {
+
    pub name: Name,
+
    pub remote: Option<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
+
    }
+
}
added radicle-cob/src/test/identity/person.rs
@@ -0,0 +1,112 @@
+
use git_ext::Oid;
+
use serde::{Deserialize, Serialize};
+

+
use crate::identity::Identity;
+
use crate::test::storage::{self, Storage};
+

+
use super::{Name, Urn};
+

+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct Person {
+
    pub payload: Payload,
+
    pub content_id: Oid,
+
}
+

+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+
pub struct Payload {
+
    name: Name,
+
    key: crypto::PublicKey,
+
}
+

+
impl Person {
+
    pub fn new(
+
        repo: &Storage,
+
        name: &str,
+
        key: crypto::PublicKey,
+
    ) -> Result<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,
+
        }
+
    }
+
}
added radicle-cob/src/test/identity/project.rs
@@ -0,0 +1,143 @@
+
use std::collections::BTreeSet;
+

+
use git_ext::Oid;
+
use serde::{Deserialize, Serialize};
+

+
use crate::identity::Identity;
+
use crate::test;
+
use crate::test::storage::{self, Storage};
+

+
use super::{Name, Urn};
+

+
pub struct RemoteProject {
+
    pub project: Project,
+
    pub person: test::Person,
+
}
+

+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct Project {
+
    pub payload: Payload,
+
    pub content_id: Oid,
+
}
+

+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+
pub struct Payload {
+
    name: Name,
+
    delegates: BTreeSet<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()),
+
        }
+
    }
+
}
added radicle-cob/src/test/storage.rs
@@ -0,0 +1,199 @@
+
use std::{collections::HashMap, convert::TryFrom as _};
+

+
use git_ref_format::RefString;
+
use tempfile::TempDir;
+

+
use crate::{
+
    change,
+
    object::{self, Commit, Reference},
+
    ObjectId, Store,
+
};
+

+
use super::identity::{RemoteProject, Urn};
+

+
pub mod error {
+
    use thiserror::Error;
+

+
    #[derive(Debug, Error)]
+
    pub enum Identity {
+
        #[error(transparent)]
+
        Json(#[from] serde_json::Error),
+
        #[error(transparent)]
+
        Git(#[from] git2::Error),
+
        #[error("'identity' was not a blob, found '{0:?}'")]
+
        NotBlob(Option<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,
+
    })
+
}
added radicle-cob/src/tests.rs
@@ -0,0 +1,253 @@
+
use std::ops::ControlFlow;
+

+
use crypto::test::signer::MockSigner;
+
use quickcheck::Arbitrary;
+
use radicle_crypto::Signer;
+

+
use crate::{
+
    create, get, history, identity::Identity, list, update, Create, ObjectId, TypeName, Update,
+
};
+

+
use super::test;
+

+
#[test]
+
fn roundtrip() {
+
    let storage = test::Storage::new();
+
    let signer = gen::<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(())
+
}
added radicle-cob/src/trailers.rs
@@ -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;
added radicle-cob/src/type_name.rs
@@ -0,0 +1,70 @@
+
// Copyright © 2022 The Radicle Link Contributors
+
//
+
// This file is part of radicle-link, distributed under the GPLv3 with Radicle
+
// Linking Exception. For full terms see the included LICENSE file.
+

+
use std::{fmt, str::FromStr};
+

+
use serde::{Deserialize, Serialize};
+
use thiserror::Error;
+

+
/// The typename of an object. Valid typenames MUST be sequences of
+
/// alphanumeric characters separated by a period. The name must start
+
/// and end with an alphanumeric character
+
///
+
/// # Examples
+
///
+
/// * `abc.def`
+
/// * `xyz.rad.issues`
+
/// * `xyz.rad.patches.releases`
+
#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
+
pub struct TypeName(String);
+

+
impl fmt::Display for TypeName {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        f.write_str(self.0.as_str())
+
    }
+
}
+

+
#[derive(Error, Debug)]
+
#[error("the type name '{invalid}' is invalid")]
+
pub struct TypeNameParse {
+
    invalid: String,
+
}
+

+
impl FromStr for TypeName {
+
    type Err = TypeNameParse;
+

+
    fn from_str(s: &str) -> Result<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());
+
    }
+
}