An Elixir toolkit for the AT Protocol. hexdocs.pm/atex
elixir bluesky atproto decentralization
25
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: repository support!

+2860 -16
+3
CHANGELOG.md
··· 10 10 11 11 ### Added 12 12 13 + - `Atex.Repo` module for building, mutating, signing, serialising, and loading 14 + AT Protocol repositories. Also supports lazily streaming from a CAR binary for 15 + efficient processing of large repository exports. 13 16 - `Atex.XRPC.UnauthedClient` module for running unauthenticated XRPC fetches on 14 17 public APIs or PDSes. 15 18 - `Atex.NSID.authority_domain/1` for deriving the `_lexicon.<authority>` DNS
+5 -4
README.md
··· 8 8 - [x] `at://` links 9 9 - [x] TIDs 10 10 - [ ] NSIDs 11 - - [ ] CIDs 12 11 - [x] Identity resolution with bi-directional validation and caching. 13 - - [x] Macro and codegen for converting Lexicon definitions to runtime schemas and structs. 12 + - [x] Macro and codegen for converting Lexicon definitions to runtime schemas 13 + and structs. 14 14 - [x] OAuth client 15 15 - [x] XRPC client 16 16 - With integration for generated Lexicon structs! 17 - - [ ] Repository reading and manipulation (MST & CAR) 17 + - [x] Repository reading and manipulation 18 18 - [x] Service auth 19 19 - [x] PLC client 20 20 - [x] XRPC server router 21 21 22 - Looking to use a data subscription service like the Firehose, [Jetstream], or [Tap]? Check out [Drinkup]. 22 + Looking to use a data subscription service like the Firehose, [Jetstream], or 23 + [Tap]? Check out [Drinkup]. 23 24 24 25 [Jetstream]: https://docs.bsky.app/blog/jetstream 25 26 [Tap]: https://github.com/bluesky-social/indigo/blob/main/cmd/tap/README.md
+198
bench/repo.exs
··· 1 + ## 2 + ## Atex.Repo benchmarks 3 + ## 4 + ## Run with: 5 + ## mix run bench/repo.exs 6 + ## 7 + ## Uses the real-world CAR fixtures in test/fixtures/ and the larger repo at 8 + ## tmp/ovyerus.car (39 MB, ~90k records) when present. 9 + ## 10 + ## Each suite section measures a distinct subsystem. Memory measurements are 11 + ## enabled with memory_time: 2 (seconds of sampling). 12 + ## 13 + 14 + alias Atex.Repo 15 + 16 + fixture = fn name -> 17 + File.read!(Path.join("test/fixtures", name)) 18 + end 19 + 20 + fixture_stream = fn name -> 21 + File.stream!(Path.join("test/fixtures", name), 65_536, [:raw, :binary]) 22 + end 23 + 24 + large_path = "tmp/ovyerus.car" 25 + has_large = File.exists?(large_path) 26 + 27 + if has_large do 28 + IO.puts("Large fixture (#{large_path}) found - including in streaming benchmarks.\n") 29 + else 30 + IO.puts("Large fixture (#{large_path}) not found - skipping large-file benchmarks.\n") 31 + end 32 + 33 + # --------------------------------------------------------------------------- 34 + # Pre-load repos used as inputs to export / access benchmarks 35 + # --------------------------------------------------------------------------- 36 + 37 + # ~22 KB, 62 records 38 + small_bin = fixture.("alt.car") 39 + # ~46 KB, 123 records 40 + medium_bin = fixture.("comet.car") 41 + 42 + {:ok, small_repo} = Repo.from_car(small_bin) 43 + {:ok, medium_repo} = Repo.from_car(medium_bin) 44 + 45 + # Pre-fetch one path from each for the get_record benchmark 46 + {:ok, small_pairs} = MST.to_list(small_repo.tree) 47 + {:ok, medium_pairs} = MST.to_list(medium_repo.tree) 48 + 49 + small_path = small_pairs |> Enum.at(div(length(small_pairs), 2)) |> elem(0) 50 + medium_path = medium_pairs |> Enum.at(div(length(medium_pairs), 2)) |> elem(0) 51 + 52 + small_collection = 53 + small_pairs |> hd() |> elem(0) |> String.split("/") |> hd() 54 + 55 + medium_collection = 56 + medium_pairs |> hd() |> elem(0) |> String.split("/") |> hd() 57 + 58 + # Repos need a signed commit to be exportable via to_car. 59 + jwk = JOSE.JWK.generate_key({:ec, "P-256"}) 60 + {:ok, small_repo_committed} = Repo.commit(small_repo, small_repo.commit.did, jwk) 61 + {:ok, medium_repo_committed} = Repo.commit(medium_repo, medium_repo.commit.did, jwk) 62 + 63 + IO.puts("=== CAR import ===\n") 64 + 65 + Benchee.run( 66 + %{ 67 + "from_car - small (62 records, ~22 KB)" => fn -> Repo.from_car(small_bin) end, 68 + "from_car - medium (123 records, ~46 KB)" => fn -> Repo.from_car(medium_bin) end 69 + }, 70 + time: 5, 71 + memory_time: 2, 72 + print: [fast_warning: false] 73 + ) 74 + 75 + IO.puts("\n=== CAR export (to_car) ===\n") 76 + 77 + Benchee.run( 78 + %{ 79 + "to_car - small (62 records)" => fn -> Repo.to_car(small_repo_committed) end, 80 + "to_car - medium (123 records)" => fn -> Repo.to_car(medium_repo_committed) end 81 + }, 82 + time: 5, 83 + memory_time: 2, 84 + print: [fast_warning: false] 85 + ) 86 + 87 + IO.puts("\n=== CAR streaming - small fixtures ===\n") 88 + 89 + Benchee.run( 90 + %{ 91 + "stream_car full - small (62 records)" => fn -> 92 + fixture_stream.("alt.car") 93 + |> Repo.stream_car() 94 + |> Stream.run() 95 + end, 96 + "stream_car full - medium (123 records)" => fn -> 97 + fixture_stream.("comet.car") 98 + |> Repo.stream_car() 99 + |> Stream.run() 100 + end, 101 + "stream_car take 10 - small" => fn -> 102 + fixture_stream.("alt.car") 103 + |> Repo.stream_car() 104 + |> Stream.filter(&match?({:record, _, _}, &1)) 105 + |> Stream.take(10) 106 + |> Stream.run() 107 + end, 108 + "stream_car take 10 - medium" => fn -> 109 + fixture_stream.("comet.car") 110 + |> Repo.stream_car() 111 + |> Stream.filter(&match?({:record, _, _}, &1)) 112 + |> Stream.take(10) 113 + |> Stream.run() 114 + end 115 + }, 116 + time: 5, 117 + memory_time: 2, 118 + print: [fast_warning: false] 119 + ) 120 + 121 + if has_large do 122 + IO.puts("\n=== CAR streaming - large fixture (39 MB, ~90k records) ===\n") 123 + 124 + Benchee.run( 125 + %{ 126 + "stream_car full - large (~90k records)" => fn -> 127 + File.stream!(large_path, 65_536, [:raw, :binary]) 128 + |> Repo.stream_car() 129 + |> Stream.run() 130 + end, 131 + "stream_car take 100 - large" => fn -> 132 + File.stream!(large_path, 65_536, [:raw, :binary]) 133 + |> Repo.stream_car() 134 + |> Stream.filter(&match?({:record, _, _}, &1)) 135 + |> Stream.take(100) 136 + |> Stream.run() 137 + end 138 + }, 139 + time: 10, 140 + memory_time: 3, 141 + warmup: 2, 142 + print: [fast_warning: false] 143 + ) 144 + end 145 + 146 + IO.puts("\n=== Record access ===\n") 147 + 148 + Benchee.run( 149 + %{ 150 + "get_record - small repo" => fn -> Repo.get_record(small_repo, small_path) end, 151 + "get_record - medium repo" => fn -> Repo.get_record(medium_repo, medium_path) end, 152 + "list_collections - small (#{length(small_pairs)} records)" => fn -> 153 + Repo.list_collections(small_repo) 154 + end, 155 + "list_collections - medium (#{length(medium_pairs)} records)" => fn -> 156 + Repo.list_collections(medium_repo) 157 + end, 158 + "list_record_keys - small, 1 collection" => fn -> 159 + Repo.list_record_keys(small_repo, small_collection) 160 + end, 161 + "list_record_keys - medium, 1 collection" => fn -> 162 + Repo.list_record_keys(medium_repo, medium_collection) 163 + end, 164 + "list_records - small, 1 collection" => fn -> 165 + Repo.list_records(small_repo, small_collection) 166 + end, 167 + "list_records - medium, 1 collection" => fn -> 168 + Repo.list_records(medium_repo, medium_collection) 169 + end 170 + }, 171 + time: 5, 172 + memory_time: 2, 173 + print: [fast_warning: false] 174 + ) 175 + 176 + IO.puts("\n=== Record mutation ===\n") 177 + 178 + Benchee.run( 179 + %{ 180 + "put_record - small repo" => fn -> 181 + Repo.put_record(small_repo, "app.bsky.feed.post/bench#{System.unique_integer()}", %{ 182 + "text" => "bench" 183 + }) 184 + end, 185 + "put_record - medium repo" => fn -> 186 + Repo.put_record( 187 + medium_repo, 188 + "app.bsky.feed.post/bench#{System.unique_integer()}", 189 + %{"text" => "bench"} 190 + ) 191 + end, 192 + "delete_record - small repo" => fn -> Repo.delete_record(small_repo, small_path) end, 193 + "delete_record - medium repo" => fn -> Repo.delete_record(medium_repo, medium_path) end 194 + }, 195 + time: 5, 196 + memory_time: 2, 197 + print: [fast_warning: false] 198 + )
+850
lib/atex/repo.ex
··· 1 + defmodule Atex.Repo do 2 + @moduledoc """ 3 + AT Protocol repository - a signed, content-addressed store of records. 4 + 5 + A repository is a key/value mapping of repo paths (`collection/rkey`) to 6 + records (CBOR objects), backed by a Merkle Search Tree (MST). Each published 7 + version of the tree is captured in a signed `Atex.Repo.Commit`. 8 + 9 + ## Quick start 10 + 11 + # Create a new empty repository 12 + repo = Atex.Repo.new() 13 + 14 + # Insert records (string path or Atex.Repo.Path struct) 15 + {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"text" => "hello"}) 16 + 17 + # Commit (sign) the current tree state 18 + jwk = JOSE.JWK.generate_key({:ec, "P-256"}) 19 + {:ok, repo} = Atex.Repo.commit(repo, "did:plc:example", jwk) 20 + 21 + # Export to a CAR file 22 + {:ok, car_binary} = Atex.Repo.to_car(repo) 23 + 24 + # Round-trip import 25 + {:ok, repo2} = Atex.Repo.from_car(car_binary) 26 + 27 + # Verify the commit signature 28 + :ok = Atex.Repo.verify_commit(repo2, JOSE.JWK.to_public(jwk)) 29 + 30 + ## Paths 31 + 32 + Record paths can be passed as plain strings (`"collection/rkey"`) or as 33 + `Atex.Repo.Path` structs. Both are accepted by all path-taking functions. 34 + See `Atex.Repo.Path` for validation rules and struct API. 35 + 36 + ## Record storage 37 + 38 + Records are DRISL CBOR-encoded. Their CIDs (`:drisl` codec) are stored as 39 + leaf values in the MST. The raw record bytes are tracked in a separate 40 + `blocks` map inside the struct so they are available for CAR export without 41 + re-encoding. 42 + 43 + ## CAR serialization 44 + 45 + `to_car/1` produces a CARv1 file in the streamable block order described in 46 + the spec: commit first, then MST nodes in depth-first pre-order, interleaved 47 + with their record blocks. 48 + 49 + `from_car/1` decodes a CAR file, extracts the signed commit from the first 50 + root CID, loads the MST, and collects all record blocks. It does **not** 51 + verify the commit signature - call `verify_commit/2` explicitly. 52 + 53 + `stream_car/1` provides a lazy stream over a CAR binary, emitting 54 + `{:commit, commit}` then `{:record, path, record}` tuples without loading 55 + the full repository into memory. Requires a streamable-order CAR (commit 56 + first, MST nodes in pre-order before their records). 57 + 58 + ATProto spec: https://atproto.com/specs/repository 59 + """ 60 + 61 + use TypedStruct 62 + alias Atex.{Repo.Commit, Repo.Path, TID} 63 + alias DASL.{CAR, CID, DRISL} 64 + alias MST.{Node, Store, Tree} 65 + 66 + typedstruct enforce: true do 67 + @typedoc "An AT Protocol repository." 68 + 69 + field :tree, Tree.t() 70 + field :commit, Commit.t() | nil 71 + field :blocks, %{CID.t() => binary()}, default: %{} 72 + end 73 + 74 + @doc """ 75 + Returns a new empty repository with no records and no commit. 76 + 77 + ## Examples 78 + 79 + iex> repo = Atex.Repo.new() 80 + iex> repo.commit 81 + nil 82 + 83 + """ 84 + @spec new() :: t() 85 + def new do 86 + %__MODULE__{ 87 + tree: MST.new(), 88 + commit: nil, 89 + blocks: %{} 90 + } 91 + end 92 + 93 + @doc """ 94 + Retrieves the record at `path`, returning the decoded map. 95 + 96 + `path` may be a `"collection/rkey"` string or an `Atex.Repo.Path` struct. 97 + 98 + Returns `{:error, :not_found}` if the path does not exist. 99 + 100 + ## Examples 101 + 102 + iex> repo = Atex.Repo.new() 103 + iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"text" => "hi"}) 104 + iex> {:ok, record} = Atex.Repo.get_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a") 105 + iex> record["text"] 106 + "hi" 107 + 108 + """ 109 + @spec get_record(t(), String.t() | Path.t()) :: 110 + {:ok, map()} 111 + | {:error, :not_found | :invalid_path | :invalid_collection | :invalid_rkey | atom()} 112 + def get_record(%__MODULE__{} = repo, path) do 113 + with {:ok, path_str} <- coerce_path(path), 114 + {:ok, cid} <- MST.get(repo.tree, path_str), 115 + {:ok, bytes} <- fetch_block(repo.blocks, cid), 116 + {:ok, record, _rest} <- DRISL.decode(bytes) do 117 + {:ok, record} 118 + end 119 + end 120 + 121 + @doc """ 122 + Inserts or replaces the record at `path`. 123 + 124 + `path` may be a `"collection/rkey"` string or an `Atex.Repo.Path` struct. 125 + 126 + The record is DRISL CBOR-encoded and its CID computed. The CID is inserted 127 + into the MST as a leaf value. The commit is **not** updated - call 128 + `commit/3` to sign the new tree state. 129 + 130 + ## Examples 131 + 132 + iex> repo = Atex.Repo.new() 133 + iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"text" => "hi"}) 134 + iex> {:ok, record} = Atex.Repo.get_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a") 135 + iex> record["text"] 136 + "hi" 137 + 138 + """ 139 + @spec put_record(t(), String.t() | Path.t(), map()) :: 140 + {:ok, t()} | {:error, :invalid_path | :invalid_collection | :invalid_rkey | atom()} 141 + def put_record(%__MODULE__{} = repo, path, record) when is_map(record) do 142 + with {:ok, path_str} <- coerce_path(path), 143 + {:ok, bytes} <- DRISL.encode(record), 144 + cid = CID.compute(bytes, :drisl), 145 + {:ok, tree} <- MST.put(repo.tree, path_str, cid) do 146 + {:ok, %{repo | tree: tree, blocks: Map.put(repo.blocks, cid, bytes)}} 147 + end 148 + end 149 + 150 + @doc """ 151 + Removes the record at `path`. 152 + 153 + `path` may be a `"collection/rkey"` string or an `Atex.Repo.Path` struct. 154 + 155 + Returns `{:error, :not_found}` if the path does not exist. 156 + 157 + ## Examples 158 + 159 + iex> repo = Atex.Repo.new() 160 + iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"text" => "hi"}) 161 + iex> {:ok, repo} = Atex.Repo.delete_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a") 162 + iex> Atex.Repo.get_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a") 163 + {:error, :not_found} 164 + 165 + """ 166 + @spec delete_record(t(), String.t() | Path.t()) :: 167 + {:ok, t()} 168 + | {:error, :not_found | :invalid_path | :invalid_collection | :invalid_rkey | atom()} 169 + def delete_record(%__MODULE__{} = repo, path) do 170 + with {:ok, path_str} <- coerce_path(path), 171 + {:ok, tree} <- MST.delete(repo.tree, path_str) do 172 + {:ok, %{repo | tree: tree}} 173 + end 174 + end 175 + 176 + @doc """ 177 + Signs the current tree state and stores the result as the repository commit. 178 + 179 + Builds an `Atex.Repo.Commit` for `did` referencing the current MST root, 180 + signs it with `signing_key`, and updates `repo.commit`. The `rev` is set to 181 + the current timestamp as a TID string, guaranteed to be monotonically 182 + increasing relative to any previous commit in this process. 183 + 184 + ## Examples 185 + 186 + iex> repo = Atex.Repo.new() 187 + iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"}) 188 + iex> {:ok, repo} = Atex.Repo.commit(repo, "did:plc:example", jwk) 189 + iex> repo.commit.did 190 + "did:plc:example" 191 + iex> repo.commit.version 192 + 3 193 + 194 + """ 195 + @spec commit(t(), String.t(), JOSE.JWK.t()) :: {:ok, t()} | {:error, atom()} 196 + def commit(%__MODULE__{} = repo, did, signing_key) do 197 + data_cid = mst_root_cid(repo.tree) 198 + rev = TID.now() |> TID.encode() 199 + 200 + unsigned = 201 + Commit.new( 202 + did: did, 203 + data: data_cid, 204 + rev: rev, 205 + prev: nil 206 + ) 207 + 208 + with {:ok, signed} <- Commit.sign(unsigned, signing_key) do 209 + {:ok, %{repo | commit: signed}} 210 + end 211 + end 212 + 213 + @doc """ 214 + Returns a deduplicated list of all collection names in the repository. 215 + 216 + Collections are returned in MST key order (bytewise-lexicographic on the 217 + full `collection/rkey` path string). This is generally close to but not 218 + identical to alphabetical order - for example, `"foo.bar"` sorts after 219 + `"foo.bar.baz"` because `/` (0x2F) > `.` (0x2E). 220 + 221 + ## Examples 222 + 223 + iex> repo = Atex.Repo.new() 224 + iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/aaaa", %{}) 225 + iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.like/bbbb", %{}) 226 + iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/bbbb", %{}) 227 + iex> {:ok, cols} = Atex.Repo.list_collections(repo) 228 + iex> cols 229 + ["app.bsky.feed.like", "app.bsky.feed.post"] 230 + 231 + """ 232 + @spec list_collections(t()) :: {:ok, [String.t()]} | {:error, atom()} 233 + def list_collections(%__MODULE__{tree: tree}) do 234 + result = 235 + tree 236 + |> MST.stream() 237 + |> Stream.map(fn {key, _cid} -> collection_from_key(key) end) 238 + |> Stream.dedup() 239 + |> Enum.to_list() 240 + 241 + {:ok, result} 242 + rescue 243 + e -> {:error, {:stream_error, e}} 244 + end 245 + 246 + @doc """ 247 + Returns a sorted list of all record keys within `collection`. 248 + 249 + The list is in MST key order, which for TID-keyed records is chronological. 250 + Returns an empty list (not an error) when the collection exists in the repo 251 + but has no records, or does not exist at all. 252 + 253 + ## Examples 254 + 255 + iex> repo = Atex.Repo.new() 256 + iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/aaaa", %{}) 257 + iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/bbbb", %{}) 258 + iex> {:ok, keys} = Atex.Repo.list_record_keys(repo, "app.bsky.feed.post") 259 + iex> keys 260 + ["aaaa", "bbbb"] 261 + 262 + """ 263 + @spec list_record_keys(t(), String.t()) :: {:ok, [String.t()]} | {:error, atom()} 264 + def list_record_keys(%__MODULE__{tree: tree}, collection) when is_binary(collection) do 265 + prefix = collection <> "/" 266 + 267 + result = 268 + tree 269 + |> MST.stream() 270 + |> stream_collection(prefix) 271 + |> Stream.map(fn {key, _cid} -> String.slice(key, byte_size(prefix)..-1//1) end) 272 + |> Enum.to_list() 273 + 274 + {:ok, result} 275 + rescue 276 + e -> {:error, {:stream_error, e}} 277 + end 278 + 279 + @doc """ 280 + Returns a sorted list of `{rkey, record_map}` pairs for all records in 281 + `collection`. 282 + 283 + The list is in MST key order. Returns an empty list when the collection does 284 + not exist or has no records. 285 + 286 + ## Examples 287 + 288 + iex> repo = Atex.Repo.new() 289 + iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/aaaa", %{"n" => 1}) 290 + iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/bbbb", %{"n" => 2}) 291 + iex> {:ok, records} = Atex.Repo.list_records(repo, "app.bsky.feed.post") 292 + iex> Enum.map(records, fn {rkey, _} -> rkey end) 293 + ["aaaa", "bbbb"] 294 + 295 + """ 296 + @spec list_records(t(), String.t()) :: 297 + {:ok, [{String.t(), map()}]} | {:error, atom()} 298 + def list_records(%__MODULE__{tree: tree, blocks: blocks}, collection) 299 + when is_binary(collection) do 300 + prefix = collection <> "/" 301 + 302 + result = 303 + tree 304 + |> MST.stream() 305 + |> stream_collection(prefix) 306 + |> Enum.reduce_while([], fn {key, cid}, acc -> 307 + rkey = String.slice(key, byte_size(prefix)..-1//1) 308 + 309 + case decode_record(blocks, cid) do 310 + {:ok, record} -> {:cont, [{rkey, record} | acc]} 311 + {:error, _} = err -> {:halt, err} 312 + end 313 + end) 314 + 315 + case result do 316 + {:error, _} = err -> err 317 + pairs -> {:ok, Enum.reverse(pairs)} 318 + end 319 + rescue 320 + e -> {:error, {:stream_error, e}} 321 + end 322 + 323 + @doc """ 324 + Exports the repository as a CARv1 binary. 325 + 326 + Block ordering follows the streamable convention from the spec: 327 + 328 + 1. The signed commit block. 329 + 2. The MST root node, then MST nodes in depth-first pre-order, with each 330 + record block immediately following the MST entry that references it. 331 + 332 + Returns `{:error, :no_commit}` if `commit/3` has not been called. 333 + 334 + ## Examples 335 + 336 + iex> repo = Atex.Repo.new() 337 + iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"text" => "hello"}) 338 + iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"}) 339 + iex> {:ok, repo} = Atex.Repo.commit(repo, "did:plc:example", jwk) 340 + iex> {:ok, bin} = Atex.Repo.to_car(repo) 341 + iex> is_binary(bin) 342 + true 343 + 344 + """ 345 + @spec to_car(t()) :: {:ok, binary()} | {:error, :no_commit | atom()} 346 + def to_car(%__MODULE__{commit: nil}), do: {:error, :no_commit} 347 + 348 + def to_car(%__MODULE__{commit: commit, tree: tree, blocks: record_blocks}) do 349 + with {:ok, commit_cid} <- Commit.cid(commit), 350 + {:ok, commit_bytes} <- Commit.encode(commit), 351 + {:ok, ordered_blocks} <- collect_ordered_blocks(tree, record_blocks) do 352 + # Encode with explicit ordering: commit block must be first so that 353 + # stream_car/1 can emit {:commit, _} before any {:record, _, _} items. 354 + encode_car_ordered(commit_cid, commit_bytes, ordered_blocks) 355 + end 356 + end 357 + 358 + @doc """ 359 + Decodes a CARv1 binary into a repository struct. 360 + 361 + The first root CID in the CAR header must point to a valid signed commit 362 + block. The MST is reconstructed from the remaining `:drisl` codec blocks. 363 + Record blocks are collected into `repo.blocks`. 364 + 365 + The commit signature is **not** verified. Call `verify_commit/2` explicitly 366 + if you need to authenticate the repository. 367 + 368 + ## Examples 369 + 370 + iex> repo = Atex.Repo.new() 371 + iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"text" => "hello"}) 372 + iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"}) 373 + iex> {:ok, repo} = Atex.Repo.commit(repo, "did:plc:example", jwk) 374 + iex> {:ok, bin} = Atex.Repo.to_car(repo) 375 + iex> {:ok, repo2} = Atex.Repo.from_car(bin) 376 + iex> repo2.commit.did 377 + "did:plc:example" 378 + 379 + """ 380 + @spec from_car(binary()) :: {:ok, t()} | {:error, atom()} 381 + def from_car(binary) when is_binary(binary) do 382 + with {:ok, car} <- CAR.decode(binary), 383 + {:ok, commit_cid} <- car_root_cid(car), 384 + {:ok, commit} <- decode_commit_block(car.blocks, commit_cid), 385 + {:ok, tree, record_blocks} <- build_tree_from_car(car.blocks, commit_cid, commit.data) do 386 + {:ok, %__MODULE__{tree: tree, commit: commit, blocks: record_blocks}} 387 + end 388 + end 389 + 390 + @doc """ 391 + Returns a lazy stream over a CARv1 chunk stream, emitting decoded items 392 + without loading the full repository into memory. 393 + 394 + `chunk_stream` must be an `Enumerable` that yields binary chunks of any 395 + size - for example `File.stream!("repo.car", [], 65_536)` or a chunked 396 + HTTP response body. Passing a plain binary also works but is equivalent to 397 + loading it into memory first; prefer `from_car/1` in that case. 398 + 399 + The stream emits: 400 + 401 + - `{:commit, Atex.Repo.Commit.t()}` - the first item, decoded from the CAR 402 + root block 403 + - `{:record, Atex.Repo.Path.t(), map()}` - one per record, decoded in the 404 + order they appear in the CAR 405 + 406 + The CAR must be in streamable pre-order: commit block first, then MST nodes 407 + before their child nodes and records. This is the format produced by 408 + `to_car/1` and by spec-compliant PDS exports. For CARs with arbitrary block 409 + ordering use `from_car/1` instead. 410 + 411 + If a record block is encountered before its parent MST node has been seen 412 + (i.e. the path cannot be resolved from already-decoded nodes), the stream 413 + emits `{:error, :unresolvable_record, cid}` and halts. Parse errors raise a 414 + `RuntimeError` (consistent with `DASL.CAR.stream_decode/2` semantics). 415 + 416 + ## Examples 417 + 418 + From a file without loading it fully into memory: 419 + 420 + File.stream!("repo.car", 65_536, [:raw, :binary]) 421 + |> Atex.Repo.stream_car() 422 + |> Enum.each(fn 423 + {:commit, commit} -> IO.puts(commit.did) 424 + {:record, path, record} -> IO.inspect({to_string(path), record}) 425 + end) 426 + 427 + From a binary (e.g. in tests): 428 + 429 + iex> repo = Atex.Repo.new() 430 + iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/aaaa", %{"n" => 1}) 431 + iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"}) 432 + iex> {:ok, repo} = Atex.Repo.commit(repo, "did:plc:example", jwk) 433 + iex> {:ok, bin} = Atex.Repo.to_car(repo) 434 + iex> items = Atex.Repo.stream_car([bin]) |> Enum.to_list() 435 + iex> match?([{:commit, _} | _], items) 436 + true 437 + iex> Enum.any?(items, &match?({:record, _, _}, &1)) 438 + true 439 + 440 + Partial consumption with `Stream.take/2` works without raising: 441 + 442 + File.stream!("repo.car", 65_536, [:raw, :binary]) 443 + |> Atex.Repo.stream_car() 444 + |> Stream.filter(&match?({:record, _, _}, &1)) 445 + |> Stream.take(10) 446 + |> Enum.to_list() 447 + 448 + """ 449 + @spec stream_car(Enumerable.t()) :: Enumerable.t() 450 + def stream_car(chunk_stream) do 451 + # safe_car_decode/1 wraps CAR.stream_decode so that halting the stream 452 + # early (Stream.take, Enum.reduce_while with :halt, etc.) does not raise. 453 + # Items are emitted as each incoming chunk is processed - no buffering. 454 + chunk_stream 455 + |> safe_car_decode() 456 + |> Stream.transform( 457 + fn -> %{commit_cid: nil, cid_to_path: %{}, halted: false} end, 458 + &reduce_car_item/2, 459 + fn _ -> :ok end 460 + ) 461 + end 462 + 463 + @doc """ 464 + Verifies the commit signature against the given public key. 465 + 466 + Delegates to `Atex.Repo.Commit.verify/2`. 467 + 468 + ## Examples 469 + 470 + iex> repo = Atex.Repo.new() 471 + iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"}) 472 + iex> {:ok, repo} = Atex.Repo.commit(repo, "did:plc:example", jwk) 473 + iex> Atex.Repo.verify_commit(repo, JOSE.JWK.to_public(jwk)) 474 + :ok 475 + 476 + """ 477 + @spec verify_commit(t(), JOSE.JWK.t()) :: :ok | {:error, :no_commit | atom()} 478 + def verify_commit(%__MODULE__{commit: nil}, _jwk), do: {:error, :no_commit} 479 + 480 + def verify_commit(%__MODULE__{commit: commit}, jwk) do 481 + Commit.verify(commit, jwk) 482 + end 483 + 484 + # --------------------------------------------------------------------------- 485 + # Private - path coercion 486 + # --------------------------------------------------------------------------- 487 + 488 + @spec coerce_path(String.t() | Path.t()) :: 489 + {:ok, String.t()} | {:error, :invalid_path | :invalid_collection | :invalid_rkey} 490 + defp coerce_path(%Path{} = path), do: {:ok, Path.to_string(path)} 491 + 492 + defp coerce_path(string) when is_binary(string) do 493 + case Path.from_string(string) do 494 + {:ok, _} -> {:ok, string} 495 + {:error, _} = err -> err 496 + end 497 + end 498 + 499 + # --------------------------------------------------------------------------- 500 + # Private - collection streaming helpers 501 + # --------------------------------------------------------------------------- 502 + 503 + @spec collection_from_key(String.t()) :: String.t() 504 + defp collection_from_key(key) do 505 + key |> String.split("/", parts: 2) |> hd() 506 + end 507 + 508 + # Filters an MST key stream to only those belonging to `prefix`, halting 509 + # once the first key past the prefix is encountered (exploiting sort order). 510 + @spec stream_collection(Enumerable.t(), String.t()) :: Enumerable.t() 511 + defp stream_collection(stream, prefix) do 512 + stream 513 + |> Stream.transform(:before, fn {key, cid}, state -> 514 + cond do 515 + String.starts_with?(key, prefix) -> {[{key, cid}], :in} 516 + state == :in -> {:halt, :done} 517 + true -> {[], :before} 518 + end 519 + end) 520 + end 521 + 522 + # --------------------------------------------------------------------------- 523 + # Private - record block decoding 524 + # --------------------------------------------------------------------------- 525 + 526 + @spec decode_record(%{CID.t() => binary()}, CID.t()) :: {:ok, map()} | {:error, atom()} 527 + defp decode_record(blocks, cid) do 528 + with {:ok, bytes} <- fetch_block(blocks, cid), 529 + {:ok, record, _rest} <- DRISL.decode(bytes) do 530 + {:ok, record} 531 + end 532 + end 533 + 534 + # --------------------------------------------------------------------------- 535 + # Private - safe CAR stream wrapper 536 + # --------------------------------------------------------------------------- 537 + 538 + # Wraps CAR.stream_decode/1 in a Stream.resource that manually drives the 539 + # inner enumerable one item at a time via its suspension continuation. 540 + # 541 + # The key property: when the downstream halts early (Stream.take, etc.), 542 + # the cleanup function calls the continuation with {:halt, nil}, which 543 + # triggers DASL.CAR.StreamDecoder.finish/1. That function raises a 544 + # RuntimeError if its internal buffer is non-empty (as it will be mid-stream). 545 + # We catch that specific raise here so callers never see it. 546 + # 547 + # Genuine parse errors (truncated file, CID mismatch) still propagate because 548 + # they originate in next_fun, not in the cleanup path. 549 + @spec safe_car_decode(Enumerable.t()) :: Enumerable.t() 550 + defp safe_car_decode(chunk_stream) do 551 + # The step function suspends after every item, giving us a continuation 552 + # we can call directly: cont.({:cont, nil}) to advance, cont.({:halt, nil}) 553 + # to clean up. The continuation already has the reducer baked in from the 554 + # initial Enumerable.reduce call so subsequent steps just call it directly. 555 + step = fn item, _ -> {:suspend, item} end 556 + 557 + Stream.resource( 558 + fn -> 559 + case Enumerable.reduce(CAR.stream_decode(chunk_stream), {:cont, nil}, step) do 560 + {:suspended, item, cont} -> {item, cont} 561 + _ -> :done 562 + end 563 + end, 564 + fn 565 + :done -> 566 + {:halt, :done} 567 + 568 + {item, cont} -> 569 + next = 570 + case cont.({:cont, nil}) do 571 + {:suspended, next_item, next_cont} -> {next_item, next_cont} 572 + _ -> :done 573 + end 574 + 575 + {[item], next} 576 + end, 577 + fn 578 + :done -> 579 + :ok 580 + 581 + {_item, cont} -> 582 + try do 583 + cont.({:halt, nil}) 584 + rescue 585 + RuntimeError -> :ok 586 + end 587 + end 588 + ) 589 + end 590 + 591 + # --------------------------------------------------------------------------- 592 + # Private - stream_car incremental reducer 593 + # --------------------------------------------------------------------------- 594 + 595 + # State fields: 596 + # commit_cid - the root CID from the CAR header (first root) 597 + # cid_to_path - %{record_value_CID => "collection/rkey"}, built as MST 598 + # node blocks arrive in pre-order 599 + # halted - true after an unrecoverable error; blocks are skipped but 600 + # the source stream is always allowed to finish naturally 601 + 602 + @spec reduce_car_item(DASL.CAR.StreamDecoder.stream_item(), map()) :: {list(), map()} 603 + defp reduce_car_item(_item, %{halted: true} = state), do: {[], state} 604 + 605 + defp reduce_car_item({:header, _version, [root | _]}, state) do 606 + {[], %{state | commit_cid: root}} 607 + end 608 + 609 + defp reduce_car_item({:header, _version, []}, state) do 610 + {[{:error, :no_root}], %{state | halted: true}} 611 + end 612 + 613 + defp reduce_car_item({:block, cid, data}, %{commit_cid: commit_cid} = state) do 614 + cond do 615 + cid == commit_cid -> 616 + case Commit.decode(data) do 617 + {:ok, commit, _} -> {[{:commit, commit}], state} 618 + {:error, reason} -> {[{:error, reason}], %{state | halted: true}} 619 + end 620 + 621 + cid.codec == :drisl -> 622 + case Node.decode(data) do 623 + {:ok, node} -> 624 + full_keys = MST.Node.keys(node) 625 + 626 + cid_to_path = 627 + node.entries 628 + |> Enum.zip(full_keys) 629 + |> Enum.reduce(state.cid_to_path, fn {entry, key}, acc -> 630 + Map.put(acc, entry.value.bytes, key) 631 + end) 632 + 633 + {[], %{state | cid_to_path: cid_to_path}} 634 + 635 + {:error, :decode, _} -> 636 + emit_record_block(cid, data, state) 637 + end 638 + 639 + true -> 640 + emit_record_block(cid, data, state) 641 + end 642 + end 643 + 644 + @spec emit_record_block(CID.t(), binary(), map()) :: {list(), map()} 645 + defp emit_record_block(cid, data, state) do 646 + case Map.fetch(state.cid_to_path, cid.bytes) do 647 + :error -> 648 + {[{:error, :unresolvable_record, cid}], %{state | halted: true}} 649 + 650 + {:ok, key} -> 651 + case DRISL.decode(data) do 652 + {:error, _} -> 653 + {[], state} 654 + 655 + {:ok, record, _} -> 656 + case String.split(key, "/", parts: 2) do 657 + [collection, rkey] -> 658 + {[{:record, %Path{collection: collection, rkey: rkey}, record}], state} 659 + 660 + _ -> 661 + {[], state} 662 + end 663 + end 664 + end 665 + end 666 + 667 + # --------------------------------------------------------------------------- 668 + # Private - CAR export helpers 669 + # --------------------------------------------------------------------------- 670 + 671 + # Encodes a CARv1 binary with the commit block guaranteed to be first, 672 + # followed by the MST and record blocks in pre-order. This ensures the output 673 + # is in streamable order per the spec and is correctly processed by stream_car/1. 674 + @spec encode_car_ordered(CID.t(), binary(), ordered_acc()) :: 675 + {:ok, binary()} | {:error, atom()} 676 + defp encode_car_ordered(commit_cid, commit_bytes, {blocks_map, rev_order}) do 677 + alias Varint.LEB128 678 + 679 + # Build each block as an iolist: [leb128_length, cid_bytes, data]. 680 + # Accumulating iolists avoids binary copying at each step; a single 681 + # :erlang.iolist_to_binary at the end does one allocation. 682 + encode_block_io = fn %CID{bytes: cid_bytes}, data -> 683 + [LEB128.encode(byte_size(cid_bytes) + byte_size(data)), cid_bytes, data] 684 + end 685 + 686 + with {:ok, header_bin} <- 687 + DRISL.encode(%{"version" => 1, "roots" => [commit_cid]}) do 688 + header_io = [LEB128.encode(byte_size(header_bin)), header_bin] 689 + commit_io = encode_block_io.(commit_cid, commit_bytes) 690 + 691 + # rev_order was built by prepending, so reverse to get pre-order sequence. 692 + rest_io = 693 + rev_order 694 + |> Enum.reverse() 695 + |> Enum.map(fn cid -> encode_block_io.(cid, Map.fetch!(blocks_map, cid)) end) 696 + 697 + {:ok, :erlang.iolist_to_binary([header_io, commit_io, rest_io])} 698 + end 699 + end 700 + 701 + # Returns {blocks_map, ordered_cids} where ordered_cids preserves pre-order 702 + # insertion sequence. This is necessary because Elixir maps do not preserve 703 + # insertion order - iterating a map in encode_car_ordered/3 would lose the 704 + # pre-order block sequencing required for streamable CARs. 705 + @type ordered_acc() :: {%{CID.t() => binary()}, [CID.t()]} 706 + 707 + @spec collect_ordered_blocks(Tree.t(), %{CID.t() => binary()}) :: 708 + {:ok, ordered_acc()} | {:error, atom()} 709 + defp collect_ordered_blocks(%Tree{root: nil}, _record_blocks) do 710 + empty = Node.empty() 711 + {:ok, bytes} = Node.encode(empty) 712 + cid = CID.compute(bytes, :drisl) 713 + {:ok, {%{cid => bytes}, [cid]}} 714 + end 715 + 716 + defp collect_ordered_blocks(%Tree{root: root, store: store}, record_blocks) do 717 + collect_node_blocks(store, root, record_blocks, {%{}, []}) 718 + end 719 + 720 + @spec collect_node_blocks(Store.t(), CID.t(), %{CID.t() => binary()}, ordered_acc()) :: 721 + {:ok, ordered_acc()} | {:error, atom()} 722 + defp collect_node_blocks(store, cid, record_blocks, {map, order}) do 723 + with {:ok, node} <- Store.get(store, cid), 724 + {:ok, node_bytes} <- Node.encode(node) do 725 + acc = {Map.put(map, cid, node_bytes), [cid | order]} 726 + 727 + Enum.reduce_while(build_preorder_steps(node), {:ok, acc}, fn step, {:ok, {map, order}} -> 728 + case step do 729 + {:node, child_cid} -> 730 + case collect_node_blocks(store, child_cid, record_blocks, {map, order}) do 731 + {:ok, acc} -> {:cont, {:ok, acc}} 732 + err -> {:halt, err} 733 + end 734 + 735 + {:record, record_cid} -> 736 + case Map.fetch(record_blocks, record_cid) do 737 + {:ok, bytes} -> 738 + {:cont, {:ok, {Map.put(map, record_cid, bytes), [record_cid | order]}}} 739 + 740 + :error -> 741 + {:cont, {:ok, {map, order}}} 742 + end 743 + end 744 + end) 745 + else 746 + {:error, :not_found} -> {:error, :missing_node} 747 + {:error, :encode, reason} -> {:error, reason} 748 + end 749 + end 750 + 751 + @spec build_preorder_steps(Node.t()) :: list() 752 + defp build_preorder_steps(node) do 753 + left_steps = if node.left, do: [{:node, node.left}], else: [] 754 + 755 + entry_steps = 756 + Enum.flat_map(node.entries, fn entry -> 757 + right_steps = if entry.right, do: [{:node, entry.right}], else: [] 758 + [{:record, entry.value} | right_steps] 759 + end) 760 + 761 + left_steps ++ entry_steps 762 + end 763 + 764 + # --------------------------------------------------------------------------- 765 + # Private - CAR import helpers 766 + # --------------------------------------------------------------------------- 767 + 768 + @spec car_root_cid(CAR.t()) :: {:ok, CID.t()} | {:error, :no_root} 769 + defp car_root_cid(%CAR{roots: [cid | _]}), do: {:ok, cid} 770 + defp car_root_cid(%CAR{roots: []}), do: {:error, :no_root} 771 + 772 + @spec decode_commit_block(%{CID.t() => binary()}, CID.t()) :: 773 + {:ok, Commit.t()} | {:error, atom()} 774 + defp decode_commit_block(blocks, cid) do 775 + with {:ok, bytes} <- fetch_block(blocks, cid), 776 + {:ok, commit, _rest} <- Commit.decode(bytes) do 777 + {:ok, commit} 778 + end 779 + end 780 + 781 + @spec build_tree_from_car(%{CID.t() => binary()}, CID.t(), CID.t() | nil) :: 782 + {:ok, Tree.t(), %{CID.t() => binary()}} | {:error, atom()} 783 + defp build_tree_from_car(blocks, commit_cid, mst_root) do 784 + result = 785 + Enum.reduce_while(blocks, {:ok, Store.Memory.new(), %{}}, fn {cid, data}, 786 + {:ok, store, rec_blocks} -> 787 + cond do 788 + cid == commit_cid -> 789 + {:cont, {:ok, store, rec_blocks}} 790 + 791 + cid.codec == :drisl -> 792 + case Node.decode(data) do 793 + {:ok, node} -> 794 + {:cont, {:ok, Store.put(store, cid, node), rec_blocks}} 795 + 796 + {:error, :decode, _reason} -> 797 + {:cont, {:ok, store, Map.put(rec_blocks, cid, data)}} 798 + end 799 + 800 + true -> 801 + {:cont, {:ok, store, Map.put(rec_blocks, cid, data)}} 802 + end 803 + end) 804 + 805 + with {:ok, store, record_blocks} <- result do 806 + {:ok, Tree.from_root(mst_root, store), record_blocks} 807 + end 808 + end 809 + 810 + # --------------------------------------------------------------------------- 811 + # Private - misc 812 + # --------------------------------------------------------------------------- 813 + 814 + @spec mst_root_cid(Tree.t()) :: CID.t() 815 + defp mst_root_cid(%Tree{root: nil}) do 816 + empty = Node.empty() 817 + {:ok, bytes} = Node.encode(empty) 818 + CID.compute(bytes, :drisl) 819 + end 820 + 821 + defp mst_root_cid(%Tree{root: cid}), do: cid 822 + 823 + @spec fetch_block(%{CID.t() => binary()}, CID.t()) :: {:ok, binary()} | {:error, :not_found} 824 + defp fetch_block(blocks, cid) do 825 + case Map.fetch(blocks, cid) do 826 + {:ok, bytes} -> {:ok, bytes} 827 + :error -> {:error, :not_found} 828 + end 829 + end 830 + end 831 + 832 + defimpl Inspect, for: Atex.Repo do 833 + import Inspect.Algebra 834 + 835 + def inspect(%Atex.Repo{commit: nil, blocks: blocks}, _opts) do 836 + concat(["#Atex.Repo<uncommitted records=", Integer.to_string(map_size(blocks)), ">"]) 837 + end 838 + 839 + def inspect(%Atex.Repo{commit: commit, blocks: blocks}, _opts) do 840 + concat([ 841 + "#Atex.Repo<", 842 + commit.did, 843 + " rev=", 844 + commit.rev, 845 + " records=", 846 + Integer.to_string(map_size(blocks)), 847 + ">" 848 + ]) 849 + end 850 + end
+334
lib/atex/repo/commit.ex
··· 1 + defmodule Atex.Repo.Commit do 2 + @moduledoc """ 3 + The signed commit object at the top of an AT Protocol repository. 4 + 5 + A commit binds together: 6 + 7 + - The account DID that owns the repository. 8 + - A CID link (`data`) to the root of the MST that holds all records. 9 + - A monotonically-increasing revision (`rev`) in TID string format, used as 10 + a logical clock. 11 + - A `prev` link to the previous commit (virtually always `nil` in v3 repos, 12 + but the field must be present in the CBOR object). 13 + - A cryptographic `sig` over the DRISL CBOR encoding of the unsigned commit. 14 + 15 + ## Signing a commit 16 + 17 + The signing convention follows the AT Protocol repository spec: 18 + 19 + 1. Build an unsigned commit (all fields except `sig`). 20 + 2. Encode it with `encode_unsigned/1` to get the DRISL CBOR bytes. 21 + 3. SHA-256 hash the bytes, then ECDSA-sign the *hash* with the account's 22 + signing key. 23 + 4. Store the raw (DER-encoded) signature bytes in `sig`. 24 + 25 + `sign/2` performs steps 2–4 in one call. Verification with `verify/2` 26 + reverses the process using a public key. 27 + 28 + ## CID computation 29 + 30 + The CID for a commit is computed from the DRISL CBOR encoding of the **signed** 31 + commit object (with `sig` present), using the `:drisl` codec. 32 + 33 + ## Wire format 34 + 35 + Map keys follow the AT Protocol specification field names: 36 + 37 + - `"did"` - account DID string 38 + - `"version"` - integer `3` 39 + - `"data"` - CID link to MST root 40 + - `"rev"` - TID string 41 + - `"prev"` - CID link or `nil` 42 + - `"sig"` - raw ECDSA signature bytes (absent from the unsigned map) 43 + 44 + ATProto spec: https://atproto.com/specs/repository#commit-objects 45 + """ 46 + 47 + use TypedStruct 48 + alias Atex.Crypto 49 + alias DASL.{CID, DRISL} 50 + 51 + @version 3 52 + 53 + typedstruct enforce: true do 54 + @typedoc "A v3 AT Protocol repository commit." 55 + 56 + field :did, String.t() 57 + field :version, pos_integer(), default: @version 58 + field :data, CID.t() 59 + field :rev, String.t() 60 + field :prev, CID.t() | nil 61 + field :sig, binary() | nil 62 + end 63 + 64 + @doc """ 65 + Builds an unsigned commit struct from the given fields. 66 + 67 + `sig` is set to `nil`. 68 + 69 + ## Options 70 + 71 + - `:did` (required) - the account DID string 72 + - `:data` (required) - `DASL.CID` pointing to the MST root 73 + - `:rev` (required) - TID string used as the logical clock 74 + - `:prev` - `DASL.CID` pointing to the previous commit, or `nil` (default) 75 + 76 + ## Examples 77 + 78 + iex> data_cid = DASL.CID.compute("data", :drisl) 79 + iex> commit = Atex.Repo.Commit.new( 80 + ...> did: "did:plc:example", 81 + ...> data: data_cid, 82 + ...> rev: "3jzfcijpj2z2a" 83 + ...> ) 84 + iex> commit.version 85 + 3 86 + iex> commit.sig 87 + nil 88 + 89 + """ 90 + @spec new(keyword()) :: t() 91 + def new(fields) do 92 + %__MODULE__{ 93 + did: Keyword.fetch!(fields, :did), 94 + version: @version, 95 + data: Keyword.fetch!(fields, :data), 96 + rev: Keyword.fetch!(fields, :rev), 97 + prev: Keyword.get(fields, :prev, nil), 98 + sig: nil 99 + } 100 + end 101 + 102 + @doc """ 103 + Serializes the commit **without** the `sig` field as DRISL CBOR. 104 + 105 + This is the payload that is hashed and signed. The `sig` field is omitted 106 + entirely from the map, as required by the spec. 107 + 108 + ## Examples 109 + 110 + iex> data_cid = DASL.CID.compute("data", :drisl) 111 + iex> commit = Atex.Repo.Commit.new(did: "did:plc:e", data: data_cid, rev: "3jzfcijpj2z2a") 112 + iex> {:ok, bin} = Atex.Repo.Commit.encode_unsigned(commit) 113 + iex> is_binary(bin) 114 + true 115 + 116 + """ 117 + @spec encode_unsigned(t()) :: {:ok, binary()} | {:error, atom()} 118 + def encode_unsigned(%__MODULE__{} = commit) do 119 + commit |> to_unsigned_map() |> DRISL.encode() 120 + end 121 + 122 + @doc """ 123 + Serializes a signed commit (including `sig`) as DRISL CBOR. 124 + 125 + Returns `{:error, :unsigned}` if `sig` is `nil`. 126 + 127 + ## Examples 128 + 129 + iex> data_cid = DASL.CID.compute("data", :drisl) 130 + iex> commit = Atex.Repo.Commit.new(did: "did:plc:e", data: data_cid, rev: "3jzfcijpj2z2a") 131 + iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"}) 132 + iex> {:ok, signed} = Atex.Repo.Commit.sign(commit, jwk) 133 + iex> {:ok, bin} = Atex.Repo.Commit.encode(signed) 134 + iex> is_binary(bin) 135 + true 136 + 137 + """ 138 + @spec encode(t()) :: {:ok, binary()} | {:error, :unsigned | atom()} 139 + def encode(%__MODULE__{sig: nil}), do: {:error, :unsigned} 140 + 141 + def encode(%__MODULE__{} = commit) do 142 + commit |> to_signed_map() |> DRISL.encode() 143 + end 144 + 145 + @doc """ 146 + Decodes a DRISL CBOR binary into a `%Atex.Repo.Commit{}`. 147 + 148 + Accepts both signed (with `"sig"`) and unsigned (without `"sig"`) payloads. 149 + 150 + ## Examples 151 + 152 + iex> data_cid = DASL.CID.compute("data", :drisl) 153 + iex> commit = Atex.Repo.Commit.new(did: "did:plc:e", data: data_cid, rev: "3jzfcijpj2z2a") 154 + iex> {:ok, bin} = Atex.Repo.Commit.encode_unsigned(commit) 155 + iex> {:ok, decoded, ""} = Atex.Repo.Commit.decode(bin) 156 + iex> decoded.did 157 + "did:plc:e" 158 + 159 + """ 160 + @spec decode(binary()) :: {:ok, t(), binary()} | {:error, atom()} 161 + def decode(binary) when is_binary(binary) do 162 + with {:ok, map, rest} <- DRISL.decode(binary), 163 + {:ok, commit} <- from_map(map) do 164 + {:ok, commit, rest} 165 + end 166 + end 167 + 168 + @doc """ 169 + Signs an unsigned commit with the given private key. 170 + 171 + Encodes the unsigned commit as DRISL CBOR and signs the bytes using 172 + `Atex.Crypto.sign/2` (SHA-256 ECDSA, low-S normalized DER output). 173 + 174 + Returns `{:error, :already_signed}` if `sig` is already present. 175 + 176 + ## Examples 177 + 178 + iex> data_cid = DASL.CID.compute("data", :drisl) 179 + iex> commit = Atex.Repo.Commit.new(did: "did:plc:e", data: data_cid, rev: "3jzfcijpj2z2a") 180 + iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"}) 181 + iex> {:ok, signed} = Atex.Repo.Commit.sign(commit, jwk) 182 + iex> is_binary(signed.sig) 183 + true 184 + 185 + """ 186 + @spec sign(t(), JOSE.JWK.t()) :: {:ok, t()} | {:error, :already_signed | atom()} 187 + def sign(%__MODULE__{sig: sig}, _jwk) when not is_nil(sig), do: {:error, :already_signed} 188 + 189 + def sign(%__MODULE__{} = commit, jwk) do 190 + with {:ok, payload} <- encode_unsigned(commit), 191 + {:ok, sig} <- Crypto.sign(payload, jwk) do 192 + {:ok, %{commit | sig: sig}} 193 + end 194 + end 195 + 196 + @doc """ 197 + Verifies the signature of a signed commit against the given public key. 198 + 199 + Returns `:ok` or `{:error, reason}`. 200 + 201 + ## Examples 202 + 203 + iex> data_cid = DASL.CID.compute("data", :drisl) 204 + iex> commit = Atex.Repo.Commit.new(did: "did:plc:e", data: data_cid, rev: "3jzfcijpj2z2a") 205 + iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"}) 206 + iex> {:ok, signed} = Atex.Repo.Commit.sign(commit, jwk) 207 + iex> Atex.Repo.Commit.verify(signed, JOSE.JWK.to_public(jwk)) 208 + :ok 209 + 210 + """ 211 + @spec verify(t(), JOSE.JWK.t()) :: :ok | {:error, :unsigned | atom()} 212 + def verify(%__MODULE__{sig: nil}, _jwk), do: {:error, :unsigned} 213 + 214 + def verify(%__MODULE__{sig: sig} = commit, jwk) do 215 + with {:ok, payload} <- encode_unsigned(commit) do 216 + Crypto.verify(payload, sig, jwk) 217 + end 218 + end 219 + 220 + @doc """ 221 + Computes the CID of a signed commit. 222 + 223 + The CID is derived from the DRISL CBOR encoding of the **signed** commit 224 + object, using the `:drisl` codec (blessed CID format). 225 + 226 + Returns `{:error, :unsigned}` if `sig` is `nil`. 227 + 228 + ## Examples 229 + 230 + iex> data_cid = DASL.CID.compute("data", :drisl) 231 + iex> commit = Atex.Repo.Commit.new(did: "did:plc:e", data: data_cid, rev: "3jzfcijpj2z2a") 232 + iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"}) 233 + iex> {:ok, signed} = Atex.Repo.Commit.sign(commit, jwk) 234 + iex> {:ok, cid} = Atex.Repo.Commit.cid(signed) 235 + iex> cid.codec 236 + :drisl 237 + 238 + """ 239 + @spec cid(t()) :: {:ok, CID.t()} | {:error, :unsigned | atom()} 240 + def cid(%__MODULE__{sig: nil}), do: {:error, :unsigned} 241 + 242 + def cid(%__MODULE__{} = commit) do 243 + with {:ok, bytes} <- encode(commit) do 244 + {:ok, CID.compute(bytes, :drisl)} 245 + end 246 + end 247 + 248 + # --------------------------------------------------------------------------- 249 + # Private helpers 250 + # --------------------------------------------------------------------------- 251 + 252 + @spec to_unsigned_map(t()) :: map() 253 + defp to_unsigned_map(%__MODULE__{} = c) do 254 + %{ 255 + "did" => c.did, 256 + "version" => c.version, 257 + "data" => c.data, 258 + "rev" => c.rev, 259 + "prev" => c.prev 260 + } 261 + end 262 + 263 + @spec to_signed_map(t()) :: map() 264 + defp to_signed_map(%__MODULE__{} = c) do 265 + c 266 + |> to_unsigned_map() 267 + |> Map.put("sig", %CBOR.Tag{tag: :bytes, value: c.sig}) 268 + end 269 + 270 + @spec from_map(map()) :: {:ok, t()} | {:error, atom()} 271 + defp from_map(map) when is_map(map) do 272 + with {:ok, did} <- fetch_string(map, "did"), 273 + {:ok, version} <- fetch_integer(map, "version"), 274 + {:ok, data} <- fetch_cid(map, "data"), 275 + {:ok, rev} <- fetch_string(map, "rev"), 276 + {:ok, prev} <- fetch_nullable_cid(map, "prev") do 277 + sig = extract_sig(Map.get(map, "sig")) 278 + 279 + {:ok, 280 + %__MODULE__{ 281 + did: did, 282 + version: version, 283 + data: data, 284 + rev: rev, 285 + prev: prev, 286 + sig: sig 287 + }} 288 + end 289 + end 290 + 291 + defp from_map(_), do: {:error, :invalid_commit} 292 + 293 + @spec extract_sig(any()) :: binary() | nil 294 + defp extract_sig(%CBOR.Tag{tag: :bytes, value: bytes}) when is_binary(bytes), do: bytes 295 + defp extract_sig(bytes) when is_binary(bytes), do: bytes 296 + defp extract_sig(_), do: nil 297 + 298 + @spec fetch_string(map(), String.t()) :: {:ok, String.t()} | {:error, atom()} 299 + defp fetch_string(map, key) do 300 + case Map.fetch(map, key) do 301 + {:ok, val} when is_binary(val) -> {:ok, val} 302 + {:ok, _} -> {:error, :invalid_commit} 303 + :error -> {:error, :missing_field} 304 + end 305 + end 306 + 307 + @spec fetch_integer(map(), String.t()) :: {:ok, integer()} | {:error, atom()} 308 + defp fetch_integer(map, key) do 309 + case Map.fetch(map, key) do 310 + {:ok, val} when is_integer(val) -> {:ok, val} 311 + {:ok, _} -> {:error, :invalid_commit} 312 + :error -> {:error, :missing_field} 313 + end 314 + end 315 + 316 + @spec fetch_cid(map(), String.t()) :: {:ok, CID.t()} | {:error, atom()} 317 + defp fetch_cid(map, key) do 318 + case Map.fetch(map, key) do 319 + {:ok, %CID{} = cid} -> {:ok, cid} 320 + {:ok, _} -> {:error, :invalid_commit} 321 + :error -> {:error, :missing_field} 322 + end 323 + end 324 + 325 + @spec fetch_nullable_cid(map(), String.t()) :: {:ok, CID.t() | nil} | {:error, atom()} 326 + defp fetch_nullable_cid(map, key) do 327 + case Map.fetch(map, key) do 328 + {:ok, %CID{} = cid} -> {:ok, cid} 329 + {:ok, nil} -> {:ok, nil} 330 + {:ok, _} -> {:error, :invalid_commit} 331 + :error -> {:ok, nil} 332 + end 333 + end 334 + end
+219
lib/atex/repo/path.ex
··· 1 + defmodule Atex.Repo.Path do 2 + @moduledoc """ 3 + A validated AT Protocol repository path - a `collection/rkey` pair. 4 + 5 + Repo paths identify individual records within a repository. They always have 6 + exactly two segments separated by a single `/`: 7 + 8 + - **collection** - a valid NSID string (e.g. `"app.bsky.feed.post"`) 9 + - **rkey** - a record key string (e.g. `"3jzfcijpj2z2a"`, `"self"`, 10 + `"example.com"`) 11 + 12 + ## Character constraints 13 + 14 + Collection segments follow NSID syntax: alphanumeric characters and periods 15 + (`A-Za-z0-9.`), at least two period-separated components. 16 + 17 + Record keys allow: `A-Za-z0-9 . - _ : ~` (per 18 + [spec](https://atproto.com/specs/record-key)), with a minimum length of 1 19 + and the values `"."` and `".."` disallowed. 20 + 21 + ## Usage 22 + 23 + iex> {:ok, path} = Atex.Repo.Path.new("app.bsky.feed.post", "3jzfcijpj2z2a") 24 + iex> to_string(path) 25 + "app.bsky.feed.post/3jzfcijpj2z2a" 26 + 27 + iex> {:ok, path} = Atex.Repo.Path.from_string("app.bsky.actor.profile/self") 28 + iex> path.collection 29 + "app.bsky.actor.profile" 30 + iex> path.rkey 31 + "self" 32 + 33 + ## `String.Chars` and interpolation 34 + 35 + `Atex.Repo.Path` implements `String.Chars`, so paths can be used directly 36 + in string interpolation and anywhere a string path is expected: 37 + 38 + iex> path = Atex.Repo.Path.new!("app.bsky.feed.post", "3jzfcijpj2z2a") 39 + iex> "Record at \#{path}" 40 + "Record at app.bsky.feed.post/3jzfcijpj2z2a" 41 + 42 + ATProto spec: https://atproto.com/specs/repository#repository-paths 43 + """ 44 + 45 + use TypedStruct 46 + 47 + # Collection: NSID - only A-Za-z0-9 and periods, must have at least one dot 48 + # (i.e. at least two components). Case-sensitive, no leading/trailing dots. 49 + @collection_re ~r/^[a-zA-Z][a-zA-Z0-9]*(?:\.[a-zA-Z][a-zA-Z0-9]*)+$/ 50 + 51 + # Record key: A-Za-z0-9 .-_:~ only, min 1 char. 52 + @rkey_re ~r/^[A-Za-z0-9.\-_:~]+$/ 53 + 54 + @reserved_rkeys [~c".", ~c".."] 55 + 56 + typedstruct enforce: true do 57 + @typedoc "A validated AT Protocol repository path (collection + rkey)." 58 + field :collection, String.t() 59 + field :rkey, String.t() 60 + end 61 + 62 + @doc """ 63 + Builds a validated `%Atex.Repo.Path{}` from a collection and record key. 64 + 65 + Returns `{:error, :invalid_collection}` if the collection is not a valid 66 + NSID, or `{:error, :invalid_rkey}` if the record key contains disallowed 67 + characters or is a reserved value (`.` or `..`). 68 + 69 + ## Examples 70 + 71 + iex> Atex.Repo.Path.new("app.bsky.feed.post", "3jzfcijpj2z2a") 72 + {:ok, %Atex.Repo.Path{collection: "app.bsky.feed.post", rkey: "3jzfcijpj2z2a"}} 73 + 74 + iex> Atex.Repo.Path.new("not-an-nsid", "self") 75 + {:error, :invalid_collection} 76 + 77 + iex> Atex.Repo.Path.new("app.bsky.feed.post", "..") 78 + {:error, :invalid_rkey} 79 + 80 + iex> Atex.Repo.Path.new("app.bsky.feed.post", "bad key!") 81 + {:error, :invalid_rkey} 82 + 83 + """ 84 + @spec new(String.t(), String.t()) :: {:ok, t()} | {:error, :invalid_collection | :invalid_rkey} 85 + def new(collection, rkey) when is_binary(collection) and is_binary(rkey) do 86 + with :ok <- validate_collection(collection), 87 + :ok <- validate_rkey(rkey) do 88 + {:ok, %__MODULE__{collection: collection, rkey: rkey}} 89 + end 90 + end 91 + 92 + @doc """ 93 + Builds a validated `%Atex.Repo.Path{}`, raising on invalid input. 94 + 95 + ## Examples 96 + 97 + iex> Atex.Repo.Path.new!("app.bsky.feed.post", "3jzfcijpj2z2a") 98 + %Atex.Repo.Path{collection: "app.bsky.feed.post", rkey: "3jzfcijpj2z2a"} 99 + 100 + """ 101 + @spec new!(String.t(), String.t()) :: t() 102 + def new!(collection, rkey) do 103 + case new(collection, rkey) do 104 + {:ok, path} -> path 105 + {:error, reason} -> raise ArgumentError, "invalid repo path: #{reason}" 106 + end 107 + end 108 + 109 + @doc """ 110 + Parses a `"collection/rkey"` string into a validated `%Atex.Repo.Path{}`. 111 + 112 + Returns `{:error, :invalid_path}` if the string does not contain exactly one 113 + `/`, or if either segment is invalid. 114 + 115 + ## Examples 116 + 117 + iex> Atex.Repo.Path.from_string("app.bsky.feed.post/3jzfcijpj2z2a") 118 + {:ok, %Atex.Repo.Path{collection: "app.bsky.feed.post", rkey: "3jzfcijpj2z2a"}} 119 + 120 + iex> Atex.Repo.Path.from_string("no-slash") 121 + {:error, :invalid_path} 122 + 123 + iex> Atex.Repo.Path.from_string("a/b/c") 124 + {:error, :invalid_path} 125 + 126 + """ 127 + @spec from_string(String.t()) :: 128 + {:ok, t()} | {:error, :invalid_path | :invalid_collection | :invalid_rkey} 129 + def from_string(string) when is_binary(string) do 130 + case String.split(string, "/") do 131 + [collection, rkey] when collection != "" and rkey != "" -> 132 + case new(collection, rkey) do 133 + {:ok, _} = ok -> ok 134 + {:error, _} = err -> err 135 + end 136 + 137 + _ -> 138 + {:error, :invalid_path} 139 + end 140 + end 141 + 142 + @doc """ 143 + Parses a `"collection/rkey"` string into a validated `%Atex.Repo.Path{}`, 144 + raising on invalid input. 145 + 146 + ## Examples 147 + 148 + iex> Atex.Repo.Path.from_string!("app.bsky.feed.post/3jzfcijpj2z2a") 149 + %Atex.Repo.Path{collection: "app.bsky.feed.post", rkey: "3jzfcijpj2z2a"} 150 + 151 + """ 152 + @spec from_string!(String.t()) :: t() 153 + def from_string!(string) when is_binary(string) do 154 + case from_string(string) do 155 + {:ok, path} -> path 156 + {:error, reason} -> raise ArgumentError, "invalid repo path: #{reason}" 157 + end 158 + end 159 + 160 + @doc """ 161 + Converts the path to its canonical `"collection/rkey"` string form. 162 + 163 + ## Examples 164 + 165 + iex> path = Atex.Repo.Path.new!("app.bsky.feed.post", "3jzfcijpj2z2a") 166 + iex> Atex.Repo.Path.to_string(path) 167 + "app.bsky.feed.post/3jzfcijpj2z2a" 168 + 169 + """ 170 + @spec to_string(t()) :: String.t() 171 + def to_string(%__MODULE__{collection: collection, rkey: rkey}), do: "#{collection}/#{rkey}" 172 + 173 + @doc """ 174 + Sigil for constructing a validated `%Atex.Repo.Path{}` from a literal string. 175 + 176 + Raises `ArgumentError` if the string is not a valid `"collection/rkey"` path. 177 + To use this sigil, import `Atex.Repo.Path`. 178 + 179 + ## Examples 180 + 181 + iex> import Atex.Repo.Path 182 + iex> ~PATH"app.bsky.feed.post/3jzfcijpj2z2a" 183 + %Atex.Repo.Path{collection: "app.bsky.feed.post", rkey: "3jzfcijpj2z2a"} 184 + 185 + """ 186 + @spec sigil_PATH(String.t(), list()) :: t() 187 + def sigil_PATH(string, _) when is_binary(string), do: from_string!(string) 188 + 189 + # --------------------------------------------------------------------------- 190 + # Private validators 191 + # --------------------------------------------------------------------------- 192 + 193 + @spec validate_collection(String.t()) :: :ok | {:error, :invalid_collection} 194 + defp validate_collection(collection) do 195 + if Regex.match?(@collection_re, collection), 196 + do: :ok, 197 + else: {:error, :invalid_collection} 198 + end 199 + 200 + @spec validate_rkey(String.t()) :: :ok | {:error, :invalid_rkey} 201 + defp validate_rkey(rkey) do 202 + cond do 203 + rkey == "" -> {:error, :invalid_rkey} 204 + String.to_charlist(rkey) in @reserved_rkeys -> {:error, :invalid_rkey} 205 + not Regex.match?(@rkey_re, rkey) -> {:error, :invalid_rkey} 206 + true -> :ok 207 + end 208 + end 209 + end 210 + 211 + defimpl String.Chars, for: Atex.Repo.Path do 212 + def to_string(path), do: Atex.Repo.Path.to_string(path) 213 + end 214 + 215 + defimpl Inspect, for: Atex.Repo.Path do 216 + def inspect(path, _opts) do 217 + ~s'~PATH"#{path}"' 218 + end 219 + end
+6 -2
mix.exs
··· 3 3 4 4 @version "0.8.0" 5 5 @github "https://github.com/cometsh/atex" 6 - @tangled "https://tangled.sh/@comet.sh/atex" 6 + @tangled "https://tangled.org/@comet.sh/atex" 7 7 8 8 def project do 9 9 [ ··· 46 46 {:bandit, "~> 1.0", only: [:dev, :test]}, 47 47 {:con_cache, "~> 1.1"}, 48 48 {:mutex, "~> 3.0"}, 49 - {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false} 49 + {:dasl, "~> 0.1"}, 50 + {:mst, "~> 0.1"}, 51 + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, 52 + {:benchee, "~> 1.3", only: :dev} 50 53 ] 51 54 end 52 55 ··· 70 73 formatters: ["html"], 71 74 groups_for_modules: [ 72 75 "Data types": [Atex.AtURI, ~r/^Atex\.DID/, Atex.Handle, Atex.NSID, Atex.TID], 76 + Repository: ~r/^Atex\.Repo/, 73 77 XRPC: ~r/^Atex\.XRPC/, 74 78 PLC: [Atex.PLC], 75 79 OAuth: [Atex.Config.OAuth, ~r/^Atex\.OAuth/],
+16 -10
mix.lock
··· 1 1 %{ 2 - "bandit": {:hex, :bandit, "1.10.0", "f8293b4a4e6c06b31655ae10bd3462f59d8c5dbd1df59028a4984f10c5961147", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "43ebceb7060a4d8273e47d83e703d01b112198624ba0826980caa3f5091243c4"}, 2 + "bandit": {:hex, :bandit, "1.10.4", "02b9734c67c5916a008e7eb7e2ba68aaea6f8177094a5f8d95f1fb99069aac17", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "a5faf501042ac1f31d736d9d4a813b3db4ef812e634583b6a457b0928798a51d"}, 3 + "benchee": {:hex, :benchee, "1.5.0", "4d812c31d54b0ec0167e91278e7de3f596324a78a096fd3d0bea68bb0c513b10", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.1", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "5b075393aea81b8ae74eadd1c28b1d87e8a63696c649d8293db7c4df3eb67535"}, 3 4 "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 4 - "cldr_utils": {:hex, :cldr_utils, "2.29.1", "11ff0a50a36a7e5f3bd9fc2fb8486a4c1bcca3081d9c080bf9e48fe0e6742e2d", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "3844a0a0ed7f42e6590ddd8bd37eb4b1556b112898f67dea3ba068c29aabd6c2"}, 5 + "cbor": {:hex, :cbor, "1.0.2", "9b0af85af291a556e10a0ffd48ba9a21a75e711828fafd3af193d56d95f0907f", [:mix], [], "hexpm", "edbc9b4a16eb93a582437b9b249c340a75af03958e338fb43d8c1be9fc65b864"}, 6 + "cldr_utils": {:hex, :cldr_utils, "2.29.5", "f43161e04acb4016f5841b2320d69120d51827f5346babb2227893a2c5916dc8", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "962d3a2028b232ee0a5373941dc411028a9442f53444a4d5d2c354f687db1835"}, 5 7 "con_cache": {:hex, :con_cache, "1.1.1", "9f47a68dfef5ac3bbff8ce2c499869dbc5ba889dadde6ac4aff8eb78ddaf6d82", [:mix], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1def4d1bec296564c75b5bbc60a19f2b5649d81bfa345a2febcc6ae380e8ae15"}, 6 - "credo": {:hex, :credo, "1.7.15", "283da72eeb2fd3ccf7248f4941a0527efb97afa224bcdef30b4b580bc8258e1c", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "291e8645ea3fea7481829f1e1eb0881b8395db212821338e577a90bf225c5607"}, 8 + "credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"}, 9 + "dasl": {:hex, :dasl, "0.1.1", "18ee2d4faa8320406cb444fa6d8e6f45c41871e6898bf1844572f83627509f72", [:mix], [{:cbor, "~> 1.0.0", [hex: :cbor, repo: "hexpm", optional: false]}, {:typedstruct, "~> 0.5", [hex: :typedstruct, repo: "hexpm", optional: false]}, {:varint, "~> 1.4", [hex: :varint, repo: "hexpm", optional: false]}], "hexpm", "ba06404cfb343ea92f17b466eaf4efc57f08958d8b3110464bd7d368fc4c4013"}, 7 10 "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 11 + "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, 8 12 "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, 9 13 "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 10 14 "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, 11 - "ex_cldr": {:hex, :ex_cldr, "2.44.1", "0d220b175874e1ce77a0f7213bdfe700b9be11aefbf35933a0e98837803ebdc5", [:mix], [{:cldr_utils, "~> 2.28", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.19 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: true]}], "hexpm", "3880cd6137ea21c74250cd870d3330c4a9fdec07fabd5e37d1b239547929e29b"}, 12 - "ex_doc": {:hex, :ex_doc, "0.39.3", "519c6bc7e84a2918b737aec7ef48b96aa4698342927d080437f61395d361dcee", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "0590955cf7ad3b625780ee1c1ea627c28a78948c6c0a9b0322bd976a079996e1"}, 15 + "ex_cldr": {:hex, :ex_cldr, "2.47.2", "c866f4b45523abd25eea3e5252eb91364296dd15bddf970db1c78cd38f25df9a", [:mix], [{:cldr_utils, "~> 2.29", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.19 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: true]}], "hexpm", "4a7cef380a1c2546166b45d6ee5e8e2f707ea695b12ae6dadd250201588b4f16"}, 16 + "ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"}, 13 17 "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, 14 - "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, 18 + "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, 15 19 "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, 16 20 "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 17 21 "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"}, 18 22 "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 19 23 "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 20 - "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 24 + "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"}, 21 25 "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, 22 26 "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, 27 + "mst": {:hex, :mst, "0.1.0", "407b9a36e7e9ccfeaaee28ce5489d32d3f9c263bc7aaff16650110a3f6db796f", [:mix], [{:dasl, "~> 0.1.0", [hex: :dasl, repo: "hexpm", optional: false]}, {:typedstruct, "~> 0.5", [hex: :typedstruct, repo: "hexpm", optional: false]}], "hexpm", "80a1f0768122534c1b592d5c77408169e7fee59247275cec4e03243bfb8a3ed5"}, 23 28 "multiformats_ex": {:hex, :multiformats_ex, "0.2.0", "5b0a3faa1a770dc671aa8a89b6323cc20b0ecf67dc93dcd21312151fbea6b4ee", [:mix], [{:varint, "~> 1.4", [hex: :varint, repo: "hexpm", optional: false]}], "hexpm", "aa406d9addb06dc197e0e92212992486af6599158d357680f29f2d11e08d0423"}, 24 - "mutex": {:hex, :mutex, "3.0.2", "528877fd0dbc09fc93ad667e10ea0d35a2126fa85205822f9dca85e87d732245", [:mix], [], "hexpm", "0a8f2ed3618160dca6a1e3520b293dc3c2ae53116265e71b4a732d35d29aa3c6"}, 29 + "mutex": {:hex, :mutex, "3.0.3", "26408c7c518b10da5c37bc4a95511b8ac1d4841f86780e947fb683eede682952", [:mix], [], "hexpm", "fb2d7d5fc1174f6c812fa0289c907cfae10793d7bd02eadd46faea2cb1516eb5"}, 25 30 "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 26 31 "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 27 32 "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, ··· 29 34 "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, 30 35 "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, 31 36 "recase": {:hex, :recase, "0.9.1", "82d2e2e2d4f9e92da1ce5db338ede2e4f15a50ac1141fc082b80050b9f49d96e", [:mix], [], "hexpm", "19ba03ceb811750e6bec4a015a9f9e45d16a8b9e09187f6d72c3798f454710f3"}, 32 - "req": {:hex, :req, "0.5.16", "99ba6a36b014458e52a8b9a0543bfa752cb0344b2a9d756651db1281d4ba4450", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "974a7a27982b9b791df84e8f6687d21483795882a7840e8309abdbe08bb06f09"}, 33 - "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 37 + "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, 38 + "statistex": {:hex, :statistex, "1.1.0", "7fec1eb2f580a0d2c1a05ed27396a084ab064a40cfc84246dbfb0c72a5c761e5", [:mix], [], "hexpm", "f5950ea26ad43246ba2cce54324ac394a4e7408fdcf98b8e230f503a0cba9cf5"}, 39 + "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, 34 40 "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, 35 41 "typedstruct": {:hex, :typedstruct, "0.5.4", "d1d33d58460a74f413e9c26d55e66fd633abd8ac0fb12639add9a11a60a0462a", [:make, :mix], [], "hexpm", "ffaef36d5dbaebdbf4ed07f7fb2ebd1037b2c1f757db6fb8e7bcbbfabbe608d8"}, 36 42 "varint": {:hex, :varint, "1.5.1", "17160c70d0428c3f8a7585e182468cac10bbf165c2360cf2328aaa39d3fb1795", [:mix], [], "hexpm", "24f3deb61e91cb988056de79d06f01161dd01be5e0acae61d8d936a552f1be73"},
+200
test/atex/repo/commit_test.exs
··· 1 + defmodule Atex.Repo.CommitTest do 2 + use ExUnit.Case, async: true 3 + 4 + alias Atex.Repo.Commit 5 + alias DASL.CID 6 + 7 + @did "did:plc:example" 8 + @rev "3jzfcijpj2z2a" 9 + 10 + defp data_cid, do: CID.compute("mst root", :drisl) 11 + 12 + defp unsigned_commit do 13 + Commit.new(did: @did, data: data_cid(), rev: @rev) 14 + end 15 + 16 + defp p256_jwk, do: JOSE.JWK.generate_key({:ec, "P-256"}) 17 + defp k256_jwk, do: JOSE.JWK.generate_key({:ec, "secp256k1"}) 18 + 19 + # --------------------------------------------------------------------------- 20 + # new/1 21 + # --------------------------------------------------------------------------- 22 + 23 + describe "new/1" do 24 + test "sets version to 3" do 25 + assert unsigned_commit().version == 3 26 + end 27 + 28 + test "sets sig to nil" do 29 + assert unsigned_commit().sig == nil 30 + end 31 + 32 + test "sets prev to nil by default" do 33 + assert unsigned_commit().prev == nil 34 + end 35 + 36 + test "accepts an explicit prev CID" do 37 + prev = CID.compute("prev commit", :drisl) 38 + commit = Commit.new(did: @did, data: data_cid(), rev: @rev, prev: prev) 39 + assert commit.prev == prev 40 + end 41 + end 42 + 43 + # --------------------------------------------------------------------------- 44 + # encode_unsigned/1 + decode/1 round-trip 45 + # --------------------------------------------------------------------------- 46 + 47 + describe "encode_unsigned/1 and decode/1" do 48 + test "produces valid DRISL bytes" do 49 + assert {:ok, bin} = Commit.encode_unsigned(unsigned_commit()) 50 + assert is_binary(bin) 51 + end 52 + 53 + test "round-trips unsigned commit" do 54 + commit = unsigned_commit() 55 + {:ok, bin} = Commit.encode_unsigned(commit) 56 + {:ok, decoded, rest} = Commit.decode(bin) 57 + 58 + assert rest == "" 59 + assert decoded.did == commit.did 60 + assert decoded.version == commit.version 61 + assert decoded.data == commit.data 62 + assert decoded.rev == commit.rev 63 + assert decoded.prev == commit.prev 64 + assert decoded.sig == nil 65 + end 66 + 67 + test "does not include sig in unsigned bytes" do 68 + jwk = p256_jwk() 69 + {:ok, signed} = Commit.sign(unsigned_commit(), jwk) 70 + 71 + {:ok, unsigned_bytes} = Commit.encode_unsigned(signed) 72 + {:ok, decoded, _} = Commit.decode(unsigned_bytes) 73 + 74 + assert decoded.sig == nil 75 + end 76 + end 77 + 78 + # --------------------------------------------------------------------------- 79 + # sign/2 80 + # --------------------------------------------------------------------------- 81 + 82 + describe "sign/2" do 83 + test "produces a binary signature (P-256)" do 84 + {:ok, signed} = Commit.sign(unsigned_commit(), p256_jwk()) 85 + assert is_binary(signed.sig) 86 + assert byte_size(signed.sig) > 0 87 + end 88 + 89 + test "produces a binary signature (secp256k1)" do 90 + {:ok, signed} = Commit.sign(unsigned_commit(), k256_jwk()) 91 + assert is_binary(signed.sig) 92 + end 93 + 94 + test "returns error when already signed" do 95 + {:ok, signed} = Commit.sign(unsigned_commit(), p256_jwk()) 96 + assert {:error, :already_signed} = Commit.sign(signed, p256_jwk()) 97 + end 98 + end 99 + 100 + # --------------------------------------------------------------------------- 101 + # verify/2 102 + # --------------------------------------------------------------------------- 103 + 104 + describe "verify/2" do 105 + test "accepts a valid signature (P-256)" do 106 + jwk = p256_jwk() 107 + {:ok, signed} = Commit.sign(unsigned_commit(), jwk) 108 + assert :ok = Commit.verify(signed, JOSE.JWK.to_public(jwk)) 109 + end 110 + 111 + test "accepts a valid signature (secp256k1)" do 112 + jwk = k256_jwk() 113 + {:ok, signed} = Commit.sign(unsigned_commit(), jwk) 114 + assert :ok = Commit.verify(signed, JOSE.JWK.to_public(jwk)) 115 + end 116 + 117 + test "rejects signature from a different key" do 118 + jwk_a = p256_jwk() 119 + jwk_b = p256_jwk() 120 + {:ok, signed} = Commit.sign(unsigned_commit(), jwk_a) 121 + assert {:error, _} = Commit.verify(signed, JOSE.JWK.to_public(jwk_b)) 122 + end 123 + 124 + test "rejects unsigned commit" do 125 + assert {:error, :unsigned} = Commit.verify(unsigned_commit(), p256_jwk()) 126 + end 127 + 128 + test "rejects tampered data field" do 129 + jwk = p256_jwk() 130 + {:ok, signed} = Commit.sign(unsigned_commit(), jwk) 131 + tampered = %{signed | data: CID.compute("tampered", :drisl)} 132 + assert {:error, _} = Commit.verify(tampered, JOSE.JWK.to_public(jwk)) 133 + end 134 + end 135 + 136 + # --------------------------------------------------------------------------- 137 + # encode/1 (signed) 138 + # --------------------------------------------------------------------------- 139 + 140 + describe "encode/1" do 141 + test "returns error for unsigned commit" do 142 + assert {:error, :unsigned} = Commit.encode(unsigned_commit()) 143 + end 144 + 145 + test "produces DRISL bytes for a signed commit" do 146 + jwk = p256_jwk() 147 + {:ok, signed} = Commit.sign(unsigned_commit(), jwk) 148 + assert {:ok, bin} = Commit.encode(signed) 149 + assert is_binary(bin) 150 + end 151 + 152 + test "round-trips signed commit" do 153 + jwk = p256_jwk() 154 + {:ok, signed} = Commit.sign(unsigned_commit(), jwk) 155 + {:ok, bin} = Commit.encode(signed) 156 + {:ok, decoded, _} = Commit.decode(bin) 157 + 158 + assert decoded.did == signed.did 159 + assert decoded.sig == signed.sig 160 + assert decoded.data == signed.data 161 + end 162 + end 163 + 164 + # --------------------------------------------------------------------------- 165 + # cid/1 166 + # --------------------------------------------------------------------------- 167 + 168 + describe "cid/1" do 169 + test "returns error for unsigned commit" do 170 + assert {:error, :unsigned} = Commit.cid(unsigned_commit()) 171 + end 172 + 173 + test "returns a CID with :drisl codec" do 174 + jwk = p256_jwk() 175 + {:ok, signed} = Commit.sign(unsigned_commit(), jwk) 176 + {:ok, cid} = Commit.cid(signed) 177 + assert cid.codec == :drisl 178 + end 179 + 180 + test "is stable - same commit produces the same CID" do 181 + jwk = p256_jwk() 182 + {:ok, signed} = Commit.sign(unsigned_commit(), jwk) 183 + {:ok, cid1} = Commit.cid(signed) 184 + {:ok, cid2} = Commit.cid(signed) 185 + assert cid1 == cid2 186 + end 187 + 188 + test "changes when the data field changes" do 189 + jwk = p256_jwk() 190 + {:ok, signed_a} = Commit.sign(unsigned_commit(), jwk) 191 + 192 + commit_b = Commit.new(did: @did, data: CID.compute("other mst", :drisl), rev: @rev) 193 + {:ok, signed_b} = Commit.sign(commit_b, jwk) 194 + 195 + {:ok, cid_a} = Commit.cid(signed_a) 196 + {:ok, cid_b} = Commit.cid(signed_b) 197 + assert cid_a != cid_b 198 + end 199 + end 200 + end
+280
test/atex/repo/fixtures_test.exs
··· 1 + defmodule Atex.Repo.FixturesTest do 2 + use ExUnit.Case, async: true 3 + 4 + @moduledoc """ 5 + Parses real-world AT Protocol repository CAR exports from test/fixtures/. 6 + 7 + These verify that `Atex.Repo.from_car/1` correctly handles actual PDS- 8 + exported repositories, including commit decoding, MST reconstruction, and 9 + individual record retrieval. 10 + """ 11 + 12 + alias Atex.Repo 13 + alias Atex.Repo.Path, as: RepoPath 14 + 15 + defp fixture_path(name), do: Elixir.Path.join([__DIR__, "../../fixtures", name]) 16 + defp fixture(name), do: File.read!(fixture_path(name)) 17 + defp fixture_stream(name), do: File.stream!(fixture_path(name), 65_536, [:raw, :binary]) 18 + 19 + # --------------------------------------------------------------------------- 20 + # comet.car - did:web:comet.sh 21 + # --------------------------------------------------------------------------- 22 + 23 + describe "comet.car (did:web:comet.sh)" do 24 + setup do 25 + {:ok, repo} = fixture("comet.car") |> Repo.from_car() 26 + {:ok, pairs} = MST.to_list(repo.tree) 27 + %{repo: repo, pairs: pairs} 28 + end 29 + 30 + test "decodes the commit", %{repo: repo} do 31 + assert repo.commit.did == "did:web:comet.sh" 32 + assert repo.commit.version == 3 33 + assert repo.commit.rev == "3mi3cqkyzsv22" 34 + assert is_binary(repo.commit.sig) 35 + end 36 + 37 + test "commit CID matches CAR root" do 38 + bin = fixture("comet.car") 39 + {:ok, car} = DASL.CAR.decode(bin) 40 + {:ok, repo} = Repo.from_car(bin) 41 + {:ok, commit_cid} = Atex.Repo.Commit.cid(repo.commit) 42 + assert [^commit_cid] = car.roots 43 + end 44 + 45 + test "reconstructs the correct number of records", %{pairs: pairs} do 46 + assert length(pairs) == 123 47 + end 48 + 49 + test "contains the expected collections", %{pairs: pairs} do 50 + collections = 51 + pairs 52 + |> Enum.map(fn {k, _} -> k |> String.split("/") |> hd() end) 53 + |> Enum.uniq() 54 + |> Enum.sort() 55 + 56 + assert "app.bsky.actor.profile" in collections 57 + assert "app.bsky.feed.post" in collections 58 + assert "app.bsky.feed.like" in collections 59 + assert "app.bsky.graph.follow" in collections 60 + assert "sh.tangled.actor.profile" in collections 61 + assert "sh.tangled.repo" in collections 62 + end 63 + 64 + test "retrieves the Bluesky profile record", %{repo: repo} do 65 + {:ok, profile} = Repo.get_record(repo, "app.bsky.actor.profile/self") 66 + assert profile["displayName"] == "comet.sh" 67 + assert profile["$type"] == "app.bsky.actor.profile" 68 + end 69 + 70 + test "retrieves a Tangled profile record", %{repo: repo} do 71 + {:ok, profile} = Repo.get_record(repo, "sh.tangled.actor.profile/self") 72 + assert is_map(profile) 73 + end 74 + 75 + test "returns not_found for a non-existent path", %{repo: repo} do 76 + assert {:error, :not_found} = 77 + Repo.get_record(repo, "app.bsky.feed.post/doesnotexist") 78 + end 79 + 80 + test "all MST leaf CIDs match their record blocks", %{repo: repo, pairs: pairs} do 81 + for {path, cid} <- pairs do 82 + assert {:ok, _record} = Repo.get_record(repo, path), 83 + "expected to decode record at #{path}" 84 + 85 + assert Map.has_key?(repo.blocks, cid), 86 + "expected block for #{path} (#{DASL.CID.encode(cid)}) to be present" 87 + end 88 + end 89 + 90 + test "MST root CID matches commit data field", %{repo: repo} do 91 + assert repo.tree.root == repo.commit.data 92 + end 93 + 94 + test "list_collections returns expected collections", %{repo: repo} do 95 + {:ok, cols} = Repo.list_collections(repo) 96 + assert "app.bsky.feed.post" in cols 97 + assert "app.bsky.feed.like" in cols 98 + assert "sh.tangled.actor.profile" in cols 99 + assert "sh.tangled.repo" in cols 100 + # Collections are in MST byte order, not necessarily lexicographic order. 101 + assert length(cols) == length(Enum.uniq(cols)) 102 + end 103 + 104 + test "list_record_keys returns rkeys for a collection", %{repo: repo} do 105 + {:ok, keys} = Repo.list_record_keys(repo, "app.bsky.feed.post") 106 + assert length(keys) > 0 107 + assert Enum.all?(keys, &is_binary/1) 108 + assert keys == Enum.sort(keys) 109 + end 110 + 111 + test "list_records round-trips record content", %{repo: repo} do 112 + {:ok, records} = Repo.list_records(repo, "app.bsky.actor.profile") 113 + assert length(records) == 1 114 + {"self", profile} = hd(records) 115 + assert profile["displayName"] == "comet.sh" 116 + end 117 + 118 + test "stream_car emits commit then all records (via File.stream!)" do 119 + items = fixture_stream("comet.car") |> Repo.stream_car() |> Enum.to_list() 120 + [{:commit, commit} | rest] = items 121 + assert commit.did == "did:web:comet.sh" 122 + record_items = Enum.filter(rest, &match?({:record, _, _}, &1)) 123 + assert length(record_items) == 123 124 + end 125 + 126 + test "stream_car record paths are Atex.Repo.Path structs" do 127 + fixture_stream("comet.car") 128 + |> Repo.stream_car() 129 + |> Stream.filter(&match?({:record, _, _}, &1)) 130 + |> Enum.each(fn {:record, path, _} -> 131 + assert %RepoPath{} = path 132 + end) 133 + end 134 + 135 + test "stream_car and from_car agree on all record content", %{repo: repo} do 136 + streamed = 137 + fixture_stream("comet.car") 138 + |> Repo.stream_car() 139 + |> Stream.filter(&match?({:record, _, _}, &1)) 140 + |> Enum.map(fn {:record, path, rec} -> {to_string(path), rec} end) 141 + |> Map.new() 142 + 143 + {:ok, pairs} = MST.to_list(repo.tree) 144 + 145 + for {path_str, _cid} <- pairs do 146 + {:ok, from_car_rec} = Repo.get_record(repo, path_str) 147 + 148 + assert Map.get(streamed, path_str) == from_car_rec, 149 + "mismatch at #{path_str}" 150 + end 151 + end 152 + end 153 + 154 + # --------------------------------------------------------------------------- 155 + # alt.car - did:plc:xl2n6atcb6vz3ajmf6bnbrmw 156 + # --------------------------------------------------------------------------- 157 + 158 + describe "alt.car (did:plc:xl2n6atcb6vz3ajmf6bnbrmw)" do 159 + setup do 160 + {:ok, repo} = fixture("alt.car") |> Repo.from_car() 161 + {:ok, pairs} = MST.to_list(repo.tree) 162 + %{repo: repo, pairs: pairs} 163 + end 164 + 165 + test "decodes the commit", %{repo: repo} do 166 + assert repo.commit.did == "did:plc:xl2n6atcb6vz3ajmf6bnbrmw" 167 + assert repo.commit.version == 3 168 + assert repo.commit.rev == "3mgbwezwku722" 169 + assert is_binary(repo.commit.sig) 170 + end 171 + 172 + test "commit CID matches CAR root" do 173 + bin = fixture("alt.car") 174 + {:ok, car} = DASL.CAR.decode(bin) 175 + {:ok, repo} = Repo.from_car(bin) 176 + {:ok, commit_cid} = Atex.Repo.Commit.cid(repo.commit) 177 + assert [^commit_cid] = car.roots 178 + end 179 + 180 + test "reconstructs the correct number of records", %{pairs: pairs} do 181 + assert length(pairs) == 62 182 + end 183 + 184 + test "contains the expected collections", %{pairs: pairs} do 185 + collections = 186 + pairs 187 + |> Enum.map(fn {k, _} -> k |> String.split("/") |> hd() end) 188 + |> Enum.uniq() 189 + |> Enum.sort() 190 + 191 + assert "app.bsky.actor.profile" in collections 192 + assert "app.bsky.feed.post" in collections 193 + assert "app.bsky.feed.like" in collections 194 + assert "sh.tangled.knot" in collections 195 + assert "xyz.statusphere.status" in collections 196 + end 197 + 198 + test "retrieves the Bluesky profile record", %{repo: repo} do 199 + {:ok, profile} = Repo.get_record(repo, "app.bsky.actor.profile/self") 200 + assert profile["displayName"] == "ovyerus alt" 201 + assert profile["$type"] == "app.bsky.actor.profile" 202 + end 203 + 204 + test "returns not_found for a non-existent path", %{repo: repo} do 205 + assert {:error, :not_found} = 206 + Repo.get_record(repo, "app.bsky.feed.post/doesnotexist") 207 + end 208 + 209 + test "all MST leaf CIDs match their record blocks", %{repo: repo, pairs: pairs} do 210 + for {path, cid} <- pairs do 211 + assert {:ok, _record} = Repo.get_record(repo, path), 212 + "expected to decode record at #{path}" 213 + 214 + assert Map.has_key?(repo.blocks, cid), 215 + "expected block for #{path} (#{DASL.CID.encode(cid)}) to be present" 216 + end 217 + end 218 + 219 + test "MST root CID matches commit data field", %{repo: repo} do 220 + assert repo.tree.root == repo.commit.data 221 + end 222 + 223 + test "list_collections returns expected collections", %{repo: repo} do 224 + {:ok, cols} = Repo.list_collections(repo) 225 + assert "app.bsky.feed.post" in cols 226 + assert "sh.tangled.knot" in cols 227 + assert "xyz.statusphere.status" in cols 228 + # Collections are in MST byte order, not necessarily lexicographic order. 229 + assert length(cols) == length(Enum.uniq(cols)) 230 + end 231 + 232 + test "list_record_keys returns rkeys including colon rkeys", %{repo: repo} do 233 + {:ok, keys} = Repo.list_record_keys(repo, "sh.tangled.knot") 234 + assert "localhost:6000" in keys 235 + end 236 + 237 + test "list_records round-trips record content", %{repo: repo} do 238 + {:ok, records} = Repo.list_records(repo, "app.bsky.actor.profile") 239 + assert length(records) == 1 240 + {"self", profile} = hd(records) 241 + assert profile["displayName"] == "ovyerus alt" 242 + end 243 + 244 + test "stream_car emits commit then all records (via File.stream!)" do 245 + items = fixture_stream("alt.car") |> Repo.stream_car() |> Enum.to_list() 246 + [{:commit, commit} | rest] = items 247 + assert commit.did == "did:plc:xl2n6atcb6vz3ajmf6bnbrmw" 248 + record_items = Enum.filter(rest, &match?({:record, _, _}, &1)) 249 + assert length(record_items) == 62 250 + end 251 + 252 + test "stream_car handles colon rkey paths" do 253 + paths = 254 + fixture_stream("alt.car") 255 + |> Repo.stream_car() 256 + |> Stream.filter(&match?({:record, _, _}, &1)) 257 + |> Enum.map(fn {:record, path, _} -> to_string(path) end) 258 + 259 + assert "sh.tangled.knot/localhost:6000" in paths 260 + end 261 + 262 + test "stream_car and from_car agree on all record content", %{repo: repo} do 263 + streamed = 264 + fixture_stream("alt.car") 265 + |> Repo.stream_car() 266 + |> Stream.filter(&match?({:record, _, _}, &1)) 267 + |> Enum.map(fn {:record, path, rec} -> {to_string(path), rec} end) 268 + |> Map.new() 269 + 270 + {:ok, pairs} = MST.to_list(repo.tree) 271 + 272 + for {path_str, _cid} <- pairs do 273 + {:ok, from_car_rec} = Repo.get_record(repo, path_str) 274 + 275 + assert Map.get(streamed, path_str) == from_car_rec, 276 + "mismatch at #{path_str}" 277 + end 278 + end 279 + end 280 + end
+242
test/atex/repo/path_test.exs
··· 1 + defmodule Atex.Repo.PathTest do 2 + use ExUnit.Case, async: true 3 + 4 + alias Atex.Repo.Path 5 + 6 + # --------------------------------------------------------------------------- 7 + # new/2 8 + # --------------------------------------------------------------------------- 9 + 10 + describe "new/2" do 11 + test "accepts a standard NSID collection and TID rkey" do 12 + assert {:ok, path} = Path.new("app.bsky.feed.post", "3jzfcijpj2z2a") 13 + assert path.collection == "app.bsky.feed.post" 14 + assert path.rkey == "3jzfcijpj2z2a" 15 + end 16 + 17 + test "accepts 'self' literal rkey" do 18 + assert {:ok, path} = Path.new("app.bsky.actor.profile", "self") 19 + assert path.rkey == "self" 20 + end 21 + 22 + test "accepts rkey with colon (e.g. domain name)" do 23 + assert {:ok, _} = Path.new("sh.tangled.knot", "localhost:6000") 24 + end 25 + 26 + test "accepts rkey with tilde" do 27 + assert {:ok, _} = Path.new("com.example.thing", "~1.2-3_") 28 + end 29 + 30 + test "accepts rkey with all allowed special chars" do 31 + assert {:ok, _} = Path.new("com.example.thing", "aZ0.-_:~") 32 + end 33 + 34 + test "accepts multi-segment deep NSID" do 35 + assert {:ok, _} = Path.new("codes.advent.challenge.day", "3jzfcijpj2z2a") 36 + end 37 + 38 + test "rejects collection without a dot (single segment)" do 39 + assert {:error, :invalid_collection} = Path.new("noperiod", "self") 40 + end 41 + 42 + test "rejects collection with leading dot" do 43 + assert {:error, :invalid_collection} = Path.new(".app.bsky", "self") 44 + end 45 + 46 + test "rejects collection with trailing dot" do 47 + assert {:error, :invalid_collection} = Path.new("app.bsky.", "self") 48 + end 49 + 50 + test "rejects collection with consecutive dots" do 51 + assert {:error, :invalid_collection} = Path.new("app..bsky", "self") 52 + end 53 + 54 + test "rejects collection with hyphen" do 55 + assert {:error, :invalid_collection} = Path.new("app-bsky.feed.post", "self") 56 + end 57 + 58 + test "rejects collection with uppercase segment starting char" do 59 + # NSIDs are lowercase-only at the authority level; uppercase disallowed in collection 60 + # The spec says NSID segments must start with a letter - uppercase is allowed per NSID spec 61 + # but our regex allows [a-zA-Z][a-zA-Z0-9]* - so let's just verify the regex works 62 + assert {:ok, _} = Path.new("App.Bsky.Feed", "self") 63 + end 64 + 65 + test "rejects empty rkey" do 66 + assert {:error, :invalid_rkey} = Path.new("app.bsky.feed.post", "") 67 + end 68 + 69 + test "rejects '.' rkey" do 70 + assert {:error, :invalid_rkey} = Path.new("app.bsky.feed.post", ".") 71 + end 72 + 73 + test "rejects '..' rkey" do 74 + assert {:error, :invalid_rkey} = Path.new("app.bsky.feed.post", "..") 75 + end 76 + 77 + test "rejects rkey with slash" do 78 + assert {:error, :invalid_rkey} = Path.new("app.bsky.feed.post", "a/b") 79 + end 80 + 81 + test "rejects rkey with space" do 82 + assert {:error, :invalid_rkey} = Path.new("app.bsky.feed.post", "bad key") 83 + end 84 + 85 + test "rejects rkey with @" do 86 + assert {:error, :invalid_rkey} = Path.new("app.bsky.feed.post", "@handle") 87 + end 88 + 89 + test "rejects rkey with #" do 90 + assert {:error, :invalid_rkey} = Path.new("app.bsky.feed.post", "#extra") 91 + end 92 + end 93 + 94 + # --------------------------------------------------------------------------- 95 + # new!/2 96 + # --------------------------------------------------------------------------- 97 + 98 + describe "new!/2" do 99 + test "returns the struct on valid input" do 100 + path = Path.new!("app.bsky.feed.post", "3jzfcijpj2z2a") 101 + assert %Path{collection: "app.bsky.feed.post", rkey: "3jzfcijpj2z2a"} = path 102 + end 103 + 104 + test "raises ArgumentError on invalid collection" do 105 + assert_raise ArgumentError, fn -> Path.new!("noslash", "self") end 106 + end 107 + 108 + test "raises ArgumentError on invalid rkey" do 109 + assert_raise ArgumentError, fn -> Path.new!("app.bsky.feed.post", "..") end 110 + end 111 + end 112 + 113 + # --------------------------------------------------------------------------- 114 + # from_string/1 115 + # --------------------------------------------------------------------------- 116 + 117 + # --------------------------------------------------------------------------- 118 + # from_string!/1 119 + # --------------------------------------------------------------------------- 120 + 121 + describe "from_string!/1" do 122 + test "returns the struct on a valid path string" do 123 + assert %Path{collection: "app.bsky.feed.post", rkey: "3jzfcijpj2z2a"} = 124 + Path.from_string!("app.bsky.feed.post/3jzfcijpj2z2a") 125 + end 126 + 127 + test "raises ArgumentError for a string with no slash" do 128 + assert_raise ArgumentError, fn -> Path.from_string!("no-slash") end 129 + end 130 + 131 + test "raises ArgumentError for a string with two slashes" do 132 + assert_raise ArgumentError, fn -> Path.from_string!("a/b/c") end 133 + end 134 + 135 + test "raises ArgumentError for an invalid collection segment" do 136 + assert_raise ArgumentError, fn -> Path.from_string!("bad/self") end 137 + end 138 + 139 + test "raises ArgumentError for a reserved rkey" do 140 + assert_raise ArgumentError, fn -> Path.from_string!("app.bsky.feed.post/..") end 141 + end 142 + end 143 + 144 + # --------------------------------------------------------------------------- 145 + # sigil_PATH 146 + # --------------------------------------------------------------------------- 147 + 148 + describe "sigil_PATH" do 149 + import Path, only: [sigil_PATH: 2] 150 + 151 + test "constructs a valid path from a literal string" do 152 + assert %Path{collection: "app.bsky.feed.post", rkey: "3jzfcijpj2z2a"} = 153 + ~PATH"app.bsky.feed.post/3jzfcijpj2z2a" 154 + end 155 + 156 + test "works with alternative rkey formats" do 157 + assert %Path{collection: "sh.tangled.knot", rkey: "localhost:6000"} = 158 + ~PATH"sh.tangled.knot/localhost:6000" 159 + end 160 + 161 + test "raises ArgumentError for an invalid path string" do 162 + assert_raise ArgumentError, fn -> ~PATH"not-a-valid-path" end 163 + end 164 + end 165 + 166 + describe "from_string/1" do 167 + test "parses a valid path string" do 168 + assert {:ok, path} = Path.from_string("app.bsky.feed.post/3jzfcijpj2z2a") 169 + assert path.collection == "app.bsky.feed.post" 170 + assert path.rkey == "3jzfcijpj2z2a" 171 + end 172 + 173 + test "parses a path with colon rkey" do 174 + assert {:ok, path} = Path.from_string("sh.tangled.knot/localhost:6000") 175 + assert path.rkey == "localhost:6000" 176 + end 177 + 178 + test "returns invalid_path for string with no slash" do 179 + assert {:error, :invalid_path} = Path.from_string("no-slash") 180 + end 181 + 182 + test "returns invalid_path for string with two slashes" do 183 + assert {:error, :invalid_path} = Path.from_string("a/b/c") 184 + end 185 + 186 + test "returns invalid_path for empty string" do 187 + assert {:error, :invalid_path} = Path.from_string("") 188 + end 189 + 190 + test "returns invalid_collection for bad collection segment" do 191 + assert {:error, :invalid_collection} = Path.from_string("bad/self") 192 + end 193 + 194 + test "returns invalid_rkey for reserved rkey" do 195 + assert {:error, :invalid_rkey} = Path.from_string("app.bsky.feed.post/..") 196 + end 197 + end 198 + 199 + # --------------------------------------------------------------------------- 200 + # to_string/1 and String.Chars 201 + # --------------------------------------------------------------------------- 202 + 203 + describe "to_string/1" do 204 + test "produces collection/rkey format" do 205 + path = Path.new!("app.bsky.feed.post", "3jzfcijpj2z2a") 206 + assert Path.to_string(path) == "app.bsky.feed.post/3jzfcijpj2z2a" 207 + end 208 + 209 + test "String.Chars protocol works in interpolation" do 210 + path = Path.new!("app.bsky.actor.profile", "self") 211 + assert "#{path}" == "app.bsky.actor.profile/self" 212 + end 213 + 214 + test "Kernel.to_string/1 works" do 215 + path = Path.new!("app.bsky.feed.post", "3jzfcijpj2z2a") 216 + assert Kernel.to_string(path) == "app.bsky.feed.post/3jzfcijpj2z2a" 217 + end 218 + end 219 + 220 + # --------------------------------------------------------------------------- 221 + # Inspect protocol 222 + # --------------------------------------------------------------------------- 223 + 224 + describe "Inspect" do 225 + test "renders as sigil form" do 226 + path = Path.new!("app.bsky.feed.post", "3jzfcijpj2z2a") 227 + assert inspect(path) == ~s(~PATH"app.bsky.feed.post/3jzfcijpj2z2a") 228 + end 229 + end 230 + 231 + # --------------------------------------------------------------------------- 232 + # Round-trip 233 + # --------------------------------------------------------------------------- 234 + 235 + describe "round-trip" do 236 + test "from_string |> to_string is identity" do 237 + str = "app.bsky.feed.post/3jzfcijpj2z2a" 238 + {:ok, path} = Path.from_string(str) 239 + assert Path.to_string(path) == str 240 + end 241 + end 242 + end
+507
test/atex/repo_test.exs
··· 1 + defmodule Atex.RepoTest do 2 + use ExUnit.Case, async: true 3 + 4 + alias Atex.Repo 5 + alias Atex.Repo.Path 6 + 7 + @did "did:plc:example" 8 + 9 + defp jwk, do: JOSE.JWK.generate_key({:ec, "P-256"}) 10 + 11 + defp committed_repo(key \\ nil) do 12 + key = key || jwk() 13 + repo = Repo.new() 14 + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"text" => "hello"}) 15 + {:ok, repo} = Repo.commit(repo, @did, key) 16 + {repo, key} 17 + end 18 + 19 + # --------------------------------------------------------------------------- 20 + # new/0 21 + # --------------------------------------------------------------------------- 22 + 23 + describe "new/0" do 24 + test "returns an empty repo with no commit" do 25 + repo = Repo.new() 26 + assert repo.commit == nil 27 + assert repo.blocks == %{} 28 + end 29 + end 30 + 31 + # --------------------------------------------------------------------------- 32 + # put_record/3 and get_record/2 33 + # --------------------------------------------------------------------------- 34 + 35 + describe "put_record/3 and get_record/2" do 36 + test "round-trips a record" do 37 + repo = Repo.new() 38 + record = %{"text" => "hello world", "createdAt" => "2024-01-01T00:00:00Z"} 39 + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", record) 40 + {:ok, fetched} = Repo.get_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a") 41 + assert fetched == record 42 + end 43 + 44 + test "replaces an existing record" do 45 + repo = Repo.new() 46 + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"v" => 1}) 47 + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"v" => 2}) 48 + {:ok, fetched} = Repo.get_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a") 49 + assert fetched["v"] == 2 50 + end 51 + 52 + test "returns not_found for missing path" do 53 + repo = Repo.new() 54 + assert {:error, :not_found} = Repo.get_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a") 55 + end 56 + 57 + test "stores multiple records independently" do 58 + repo = Repo.new() 59 + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/aaaa", %{"n" => 1}) 60 + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/bbbb", %{"n" => 2}) 61 + {:ok, r1} = Repo.get_record(repo, "app.bsky.feed.post/aaaa") 62 + {:ok, r2} = Repo.get_record(repo, "app.bsky.feed.post/bbbb") 63 + assert r1["n"] == 1 64 + assert r2["n"] == 2 65 + end 66 + 67 + test "rejects an invalid path string" do 68 + repo = Repo.new() 69 + assert {:error, :invalid_path} = Repo.put_record(repo, "no-slash", %{}) 70 + assert {:error, :invalid_path} = Repo.put_record(repo, "/leading", %{}) 71 + assert {:error, :invalid_path} = Repo.put_record(repo, "a/b/c", %{}) 72 + assert {:error, :invalid_path} = Repo.put_record(repo, "", %{}) 73 + end 74 + 75 + test "accepts an Atex.Repo.Path struct" do 76 + repo = Repo.new() 77 + path = Path.new!("app.bsky.feed.post", "3jzfcijpj2z2a") 78 + {:ok, repo} = Repo.put_record(repo, path, %{"text" => "via struct"}) 79 + {:ok, record} = Repo.get_record(repo, path) 80 + assert record["text"] == "via struct" 81 + end 82 + 83 + test "Path struct and equivalent string retrieve the same record" do 84 + repo = Repo.new() 85 + path = Path.new!("app.bsky.feed.post", "3jzfcijpj2z2a") 86 + {:ok, repo} = Repo.put_record(repo, path, %{"text" => "hi"}) 87 + {:ok, r1} = Repo.get_record(repo, path) 88 + {:ok, r2} = Repo.get_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a") 89 + assert r1 == r2 90 + end 91 + end 92 + 93 + # --------------------------------------------------------------------------- 94 + # delete_record/2 95 + # --------------------------------------------------------------------------- 96 + 97 + describe "delete_record/2" do 98 + test "removes an existing record" do 99 + repo = Repo.new() 100 + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"x" => 1}) 101 + {:ok, repo} = Repo.delete_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a") 102 + assert {:error, :not_found} = Repo.get_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a") 103 + end 104 + 105 + test "returns not_found for missing path" do 106 + repo = Repo.new() 107 + assert {:error, :not_found} = Repo.delete_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a") 108 + end 109 + 110 + test "does not affect other records" do 111 + repo = Repo.new() 112 + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/aaaa", %{"n" => 1}) 113 + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/bbbb", %{"n" => 2}) 114 + {:ok, repo} = Repo.delete_record(repo, "app.bsky.feed.post/aaaa") 115 + assert {:error, :not_found} = Repo.get_record(repo, "app.bsky.feed.post/aaaa") 116 + assert {:ok, %{"n" => 2}} = Repo.get_record(repo, "app.bsky.feed.post/bbbb") 117 + end 118 + 119 + test "rejects invalid path" do 120 + repo = Repo.new() 121 + assert {:error, :invalid_path} = Repo.delete_record(repo, "bad") 122 + end 123 + 124 + test "accepts an Atex.Repo.Path struct" do 125 + repo = Repo.new() 126 + path = Path.new!("app.bsky.feed.post", "aaaa") 127 + {:ok, repo} = Repo.put_record(repo, path, %{"n" => 1}) 128 + {:ok, repo} = Repo.delete_record(repo, path) 129 + assert {:error, :not_found} = Repo.get_record(repo, path) 130 + end 131 + end 132 + 133 + # --------------------------------------------------------------------------- 134 + # commit/3 135 + # --------------------------------------------------------------------------- 136 + 137 + describe "commit/3" do 138 + test "sets the commit DID" do 139 + {repo, _key} = committed_repo() 140 + assert repo.commit.did == @did 141 + end 142 + 143 + test "sets version to 3" do 144 + {repo, _key} = committed_repo() 145 + assert repo.commit.version == 3 146 + end 147 + 148 + test "sets prev to nil" do 149 + {repo, _key} = committed_repo() 150 + assert repo.commit.prev == nil 151 + end 152 + 153 + test "rev is a valid TID string" do 154 + {repo, _key} = committed_repo() 155 + assert Atex.TID.match?(repo.commit.rev) 156 + end 157 + 158 + test "produces a non-nil sig" do 159 + {repo, _key} = committed_repo() 160 + assert is_binary(repo.commit.sig) 161 + end 162 + 163 + test "data CID matches the MST root" do 164 + key = jwk() 165 + repo = Repo.new() 166 + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"x" => 1}) 167 + {:ok, repo} = Repo.commit(repo, @did, key) 168 + 169 + assert repo.commit.data == repo.tree.root 170 + end 171 + 172 + test "rev increases monotonically across sequential commits" do 173 + key = jwk() 174 + repo = Repo.new() 175 + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/aaaa", %{"n" => 1}) 176 + {:ok, repo} = Repo.commit(repo, @did, key) 177 + rev1 = repo.commit.rev 178 + 179 + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/bbbb", %{"n" => 2}) 180 + {:ok, repo} = Repo.commit(repo, @did, key) 181 + rev2 = repo.commit.rev 182 + 183 + assert rev2 > rev1 184 + end 185 + end 186 + 187 + # --------------------------------------------------------------------------- 188 + # verify_commit/2 189 + # --------------------------------------------------------------------------- 190 + 191 + describe "verify_commit/2" do 192 + test "passes with the correct public key" do 193 + key = jwk() 194 + {repo, _} = committed_repo(key) 195 + assert :ok = Repo.verify_commit(repo, JOSE.JWK.to_public(key)) 196 + end 197 + 198 + test "fails with a different key" do 199 + {repo, _key} = committed_repo() 200 + other_key = JOSE.JWK.to_public(jwk()) 201 + assert {:error, _} = Repo.verify_commit(repo, other_key) 202 + end 203 + 204 + test "returns error when no commit exists" do 205 + repo = Repo.new() 206 + assert {:error, :no_commit} = Repo.verify_commit(repo, jwk()) 207 + end 208 + end 209 + 210 + # --------------------------------------------------------------------------- 211 + # to_car/1 212 + # --------------------------------------------------------------------------- 213 + 214 + describe "to_car/1" do 215 + test "returns error when no commit exists" do 216 + repo = Repo.new() 217 + assert {:error, :no_commit} = Repo.to_car(repo) 218 + end 219 + 220 + test "returns a binary" do 221 + {repo, _key} = committed_repo() 222 + assert {:ok, bin} = Repo.to_car(repo) 223 + assert is_binary(bin) 224 + end 225 + 226 + test "CAR root is the commit CID" do 227 + {repo, _key} = committed_repo() 228 + {:ok, bin} = Repo.to_car(repo) 229 + {:ok, car} = DASL.CAR.decode(bin) 230 + {:ok, commit_cid} = Atex.Repo.Commit.cid(repo.commit) 231 + assert [^commit_cid] = car.roots 232 + end 233 + 234 + test "empty repo produces a valid CAR" do 235 + key = jwk() 236 + repo = Repo.new() 237 + {:ok, repo} = Repo.commit(repo, @did, key) 238 + assert {:ok, bin} = Repo.to_car(repo) 239 + assert is_binary(bin) 240 + end 241 + end 242 + 243 + # --------------------------------------------------------------------------- 244 + # from_car/1 245 + # --------------------------------------------------------------------------- 246 + 247 + describe "from_car/1" do 248 + test "round-trips a single-record repo" do 249 + key = jwk() 250 + repo = Repo.new() 251 + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"text" => "hi"}) 252 + {:ok, repo} = Repo.commit(repo, @did, key) 253 + {:ok, bin} = Repo.to_car(repo) 254 + 255 + {:ok, repo2} = Repo.from_car(bin) 256 + assert repo2.commit.did == @did 257 + assert {:ok, %{"text" => "hi"}} = Repo.get_record(repo2, "app.bsky.feed.post/3jzfcijpj2z2a") 258 + end 259 + 260 + test "round-trips a multi-record repo" do 261 + key = jwk() 262 + repo = Repo.new() 263 + 264 + records = [ 265 + {"app.bsky.feed.post/aaaa", %{"n" => 1}}, 266 + {"app.bsky.feed.post/bbbb", %{"n" => 2}}, 267 + {"app.bsky.actor.profile/self", %{"displayName" => "Test"}} 268 + ] 269 + 270 + repo = 271 + Enum.reduce(records, repo, fn {path, rec}, acc -> 272 + {:ok, acc} = Repo.put_record(acc, path, rec) 273 + acc 274 + end) 275 + 276 + {:ok, repo} = Repo.commit(repo, @did, key) 277 + {:ok, bin} = Repo.to_car(repo) 278 + {:ok, repo2} = Repo.from_car(bin) 279 + 280 + for {path, record} <- records do 281 + assert {:ok, ^record} = Repo.get_record(repo2, path) 282 + end 283 + end 284 + 285 + test "commit signature survives round-trip" do 286 + key = jwk() 287 + {repo, _} = committed_repo(key) 288 + {:ok, bin} = Repo.to_car(repo) 289 + {:ok, repo2} = Repo.from_car(bin) 290 + assert :ok = Repo.verify_commit(repo2, JOSE.JWK.to_public(key)) 291 + end 292 + 293 + test "returns error for invalid binary" do 294 + assert match?({:error, _, _}, Repo.from_car("not a car")) or 295 + match?({:error, _}, Repo.from_car("not a car")) 296 + end 297 + 298 + test "round-trips an empty repo" do 299 + key = jwk() 300 + repo = Repo.new() 301 + {:ok, repo} = Repo.commit(repo, @did, key) 302 + {:ok, bin} = Repo.to_car(repo) 303 + {:ok, repo2} = Repo.from_car(bin) 304 + assert repo2.commit.did == @did 305 + end 306 + end 307 + 308 + # --------------------------------------------------------------------------- 309 + # list_collections/1 310 + # --------------------------------------------------------------------------- 311 + 312 + describe "list_collections/1" do 313 + test "returns empty list for empty repo" do 314 + assert {:ok, []} = Repo.list_collections(Repo.new()) 315 + end 316 + 317 + test "returns deduplicated collection names in MST order" do 318 + repo = Repo.new() 319 + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/aaaa", %{}) 320 + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.like/bbbb", %{}) 321 + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/cccc", %{}) 322 + {:ok, cols} = Repo.list_collections(repo) 323 + # MST key order: "app.bsky.feed.like/..." < "app.bsky.feed.post/..." 324 + assert "app.bsky.feed.like" in cols 325 + assert "app.bsky.feed.post" in cols 326 + assert length(cols) == 2 327 + end 328 + 329 + test "each collection appears exactly once" do 330 + repo = Repo.new() 331 + 332 + repo = 333 + Enum.reduce(1..5, repo, fn i, acc -> 334 + {:ok, acc} = Repo.put_record(acc, "app.bsky.feed.post/key#{i}", %{"n" => i}) 335 + acc 336 + end) 337 + 338 + {:ok, cols} = Repo.list_collections(repo) 339 + assert cols == ["app.bsky.feed.post"] 340 + end 341 + end 342 + 343 + # --------------------------------------------------------------------------- 344 + # list_record_keys/2 345 + # --------------------------------------------------------------------------- 346 + 347 + describe "list_record_keys/2" do 348 + test "returns empty list for empty repo" do 349 + assert {:ok, []} = Repo.list_record_keys(Repo.new(), "app.bsky.feed.post") 350 + end 351 + 352 + test "returns empty list for non-existent collection" do 353 + repo = Repo.new() 354 + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.like/aaaa", %{}) 355 + assert {:ok, []} = Repo.list_record_keys(repo, "app.bsky.feed.post") 356 + end 357 + 358 + test "returns rkeys in sorted order" do 359 + repo = Repo.new() 360 + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/cccc", %{}) 361 + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/aaaa", %{}) 362 + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/bbbb", %{}) 363 + {:ok, keys} = Repo.list_record_keys(repo, "app.bsky.feed.post") 364 + assert keys == ["aaaa", "bbbb", "cccc"] 365 + end 366 + 367 + test "does not bleed into adjacent collections" do 368 + repo = Repo.new() 369 + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.like/aaaa", %{}) 370 + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/bbbb", %{}) 371 + {:ok, repo} = Repo.put_record(repo, "app.bsky.graph.follow/cccc", %{}) 372 + {:ok, keys} = Repo.list_record_keys(repo, "app.bsky.feed.post") 373 + assert keys == ["bbbb"] 374 + end 375 + end 376 + 377 + # --------------------------------------------------------------------------- 378 + # list_records/2 379 + # --------------------------------------------------------------------------- 380 + 381 + describe "list_records/2" do 382 + test "returns empty list for empty repo" do 383 + assert {:ok, []} = Repo.list_records(Repo.new(), "app.bsky.feed.post") 384 + end 385 + 386 + test "returns {rkey, record} pairs in sorted order" do 387 + repo = Repo.new() 388 + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/aaaa", %{"n" => 1}) 389 + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/bbbb", %{"n" => 2}) 390 + {:ok, records} = Repo.list_records(repo, "app.bsky.feed.post") 391 + assert Enum.map(records, &elem(&1, 0)) == ["aaaa", "bbbb"] 392 + assert Enum.map(records, fn {_, r} -> r["n"] end) == [1, 2] 393 + end 394 + 395 + test "only returns records for the specified collection" do 396 + repo = Repo.new() 397 + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.like/aaaa", %{"liked" => true}) 398 + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/bbbb", %{"text" => "hi"}) 399 + {:ok, records} = Repo.list_records(repo, "app.bsky.feed.post") 400 + assert length(records) == 1 401 + assert {"bbbb", %{"text" => "hi"}} = hd(records) 402 + end 403 + end 404 + 405 + # --------------------------------------------------------------------------- 406 + # stream_car/1 407 + # --------------------------------------------------------------------------- 408 + 409 + describe "stream_car/1" do 410 + defp build_committed_repo(records) do 411 + key = jwk() 412 + 413 + repo = 414 + Enum.reduce(records, Repo.new(), fn {path, rec}, acc -> 415 + {:ok, acc} = Repo.put_record(acc, path, rec) 416 + acc 417 + end) 418 + 419 + {:ok, repo} = Repo.commit(repo, @did, key) 420 + {:ok, bin} = Repo.to_car(repo) 421 + {repo, bin, key} 422 + end 423 + 424 + test "first item is {:commit, commit}" do 425 + {_repo, bin, _key} = 426 + build_committed_repo([{"app.bsky.feed.post/aaaa", %{"n" => 1}}]) 427 + 428 + [first | _] = Repo.stream_car([bin]) |> Enum.to_list() 429 + assert match?({:commit, %Atex.Repo.Commit{}}, first) 430 + end 431 + 432 + test "commit in stream has correct DID" do 433 + {_repo, bin, _key} = 434 + build_committed_repo([{"app.bsky.feed.post/aaaa", %{"n" => 1}}]) 435 + 436 + [{:commit, commit} | _] = Repo.stream_car([bin]) |> Enum.to_list() 437 + assert commit.did == @did 438 + end 439 + 440 + test "emits a {:record, path, map} for each record" do 441 + records = [ 442 + {"app.bsky.feed.post/aaaa", %{"n" => 1}}, 443 + {"app.bsky.feed.post/bbbb", %{"n" => 2}} 444 + ] 445 + 446 + {_repo, bin, _key} = build_committed_repo(records) 447 + items = Repo.stream_car([bin]) |> Enum.to_list() 448 + record_items = Enum.filter(items, &match?({:record, _, _}, &1)) 449 + assert length(record_items) == 2 450 + 451 + paths = Enum.map(record_items, fn {:record, path, _} -> to_string(path) end) |> Enum.sort() 452 + assert paths == ["app.bsky.feed.post/aaaa", "app.bsky.feed.post/bbbb"] 453 + end 454 + 455 + test "record content is correct" do 456 + {_repo, bin, _key} = 457 + build_committed_repo([{"app.bsky.feed.post/aaaa", %{"text" => "hello stream"}}]) 458 + 459 + items = Repo.stream_car([bin]) |> Enum.to_list() 460 + [{:record, path, record}] = Enum.filter(items, &match?({:record, _, _}, &1)) 461 + assert path.collection == "app.bsky.feed.post" 462 + assert path.rkey == "aaaa" 463 + assert record["text"] == "hello stream" 464 + end 465 + 466 + test "path items are Atex.Repo.Path structs" do 467 + {_repo, bin, _key} = 468 + build_committed_repo([{"app.bsky.feed.post/aaaa", %{"n" => 1}}]) 469 + 470 + items = Repo.stream_car([bin]) |> Enum.to_list() 471 + [{:record, path, _}] = Enum.filter(items, &match?({:record, _, _}, &1)) 472 + assert %Atex.Repo.Path{} = path 473 + end 474 + 475 + test "empty repo stream has only commit item" do 476 + key = jwk() 477 + repo = Repo.new() 478 + {:ok, repo} = Repo.commit(repo, @did, key) 479 + {:ok, bin} = Repo.to_car(repo) 480 + items = Repo.stream_car([bin]) |> Enum.to_list() 481 + assert length(items) == 1 482 + assert match?([{:commit, _}], items) 483 + end 484 + 485 + test "stream and from_car agree on record content" do 486 + records = [ 487 + {"app.bsky.feed.post/aaaa", %{"n" => 1}}, 488 + {"app.bsky.actor.profile/self", %{"displayName" => "Test"}} 489 + ] 490 + 491 + {_repo, bin, _key} = build_committed_repo(records) 492 + 493 + {:ok, repo} = Repo.from_car(bin) 494 + 495 + streamed = 496 + Repo.stream_car([bin]) 497 + |> Stream.filter(&match?({:record, _, _}, &1)) 498 + |> Enum.map(fn {:record, path, rec} -> {to_string(path), rec} end) 499 + |> Map.new() 500 + 501 + for {path_str, _record} <- records do 502 + {:ok, from_car_rec} = Repo.get_record(repo, path_str) 503 + assert Map.get(streamed, path_str) == from_car_rec 504 + end 505 + end 506 + end 507 + end
test/fixtures/alt.car

This is a binary file and will not be displayed.

test/fixtures/comet.car

This is a binary file and will not be displayed.