diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..44b0a0e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,212 @@ +# AGENTS.md + +Guidance for agentic coding assistants working in this repository. + +## Project Overview + +`elixir-dasl` is an Elixir library implementing DASL (Decentralized +Authenticated Structure Layer) primitives: CIDs, DRISL (CBOR profile), and CAR +archives. + +## Build / Lint / Test Commands + +```bash +# Compile +mix compile + +# Format (run before committing) +mix format + +# Check formatting without writing +mix format --check-formatted + +# Lint +mix credo + +# Run all tests +mix test + +# Run a single test file +mix test test/dasl/cid_test.exs + +# Run a single test by line number +mix test test/dasl/cid_test.exs:42 + +# Run doctests only +mix test --only doctest + +# Generate docs +mix docs +``` + +No custom Mix aliases are defined. There is no CI pipeline — validate locally. + +## Project Structure + +``` +lib/dasl/ + cid.ex # DASL.CID — struct, parse/encode/verify/compute + drisl.ex # DASL.DRISL — facade + drisl/ + decoder.ex # DASL.DRISL.Decoder + encoder.ex # DASL.DRISL.Encoder + car.ex # DASL.CAR — struct + entry-point API + car/ + decoder.ex # DASL.CAR.Decoder + encoder.ex # DASL.CAR.Encoder + stream_decoder.ex # DASL.CAR.StreamDecoder + drisl.ex # DASL.CAR.DRISL + +test/dasl/ # mirrors lib/dasl/ exactly +``` + +## Code Style + +### Module Naming + +- Domain acronyms are all-caps: `DASL`, `CAR`, `CID`, `DRISL`. +- Sub-modules follow `Parent.Role`: `DASL.CAR.Decoder`, `DASL.CAR.Encoder`. +- Module file path mirrors module name exactly. + +### Structs + +Use `TypedStruct` with `enforce: true` for all structs. Every field must be +typed. Use `default:` only where a sensible zero value exists. + +```elixir +typedstruct enforce: true do + field :version, pos_integer(), default: 1 + field :roots, list(CID.t()), default: [] + field :blocks, %{CID.t() => binary()}, default: %{} +end +``` + +### Typespecs + +- Every public function must have `@spec`. +- Every private function should have `@spec` where non-trivial. +- Define named error type aliases at the top of each module, then reference them + in `@spec` annotations: + +```elixir +@type header_error() :: {:error, :header, atom()} +@type block_error() :: {:error, :block, atom()} +@type decode_error() :: header_error() | block_error() +``` + +### Error Handling + +Consistent tagged-tuple convention — do not deviate: + +- Success: `{:ok, value}` +- Simple error: `{:error, reason}` +- Scoped error (CAR layer): `{:error, :scope, :reason}` — e.g. + `{:error, :header, :missing_roots}`, `{:error, :block, :cid_mismatch}` + +Use `with` chains for multi-step fallible operations; use `else` to remap errors +when needed. Use `Enum.reduce_while` for fallible iteration — halt on first +error. + +Bang variants (`parse_header!`, `validate_block!`) are only acceptable inside +`StreamDecoder`-style modules where the documented contract is raise-on-error. +Do not mix raise and tuple-return styles in the same module without explicit +documentation of the contract. + +### Pattern Matching and Guards + +- Prefer multi-clause function heads for exhaustive dispatch over nested + conditionals. +- Use bit-syntax binary pattern matching for low-level binary parsing (see + `DRISL.Decoder`). +- Pair guards with pattern matches for validation constraints: + +```elixir +when hash_size == @hash_size and byte_size(digest) == @hash_size +``` + +### Module Attributes for Constants + +Use `@` module attributes for all magic numbers and codec identifiers. Group +them at the top of the module, after `@moduledoc`. + +```elixir +@codec_raw 0x55 +@codec_drisl 0x71 +@hash_sha256 0x12 +@hash_size 32 +``` + +### Pipes + +Use pipes where they read naturally. Do not force them. Prefer `with` over pipes +for error-prone chains. The primary pipe use-case is stream pipelines: + +```elixir +chunk_stream +|> StreamDecoder.decode_stream(opts) +|> Stream.map(&transform/1) +``` + +### Documentation + +- Every public module must have `@moduledoc` with a prose description and, where + applicable, a `Spec: ` line linking to the relevant spec. +- Every public function must have `@doc` with: + - A prose description. Keep it high-level — do not repeat details already + covered by the spec (e.g. byte-level encoding rules, magic constants, + algorithm steps). + - An `## Options` section if the function accepts an options keyword list. + - An `## Examples` section with `iex>` doctests for the happy path and at + least one error case. +- Use dashes (`-`) for all Markdown lists in `@moduledoc` and `@doc`. Never use + asterisks (`*`). + +### Protocol Implementations + +Implement `String.Chars` and `Inspect` for domain structs at the **bottom** of +the file, outside the main module block — see `cid.ex` for the pattern. + +### Streaming + +Use `Stream.transform/4` with explicit start/reduce/after arities (not the +3-arity shorthand) for stateful streaming parsers. + +### Section Separators + +Use `# ---...---` comment separators (78 dashes) to group related functions +visually, consistent with existing source files. + +## Test Style + +- All test modules: `use ExUnit.Case, async: true`. +- Pull doctests in at the top: `doctest DASL.ModuleName`. +- Use `describe/test` blocks — one `describe` per public function or logical + group. +- Shared fixtures: define as `@` module attributes or `defp` helpers at the top + of the test module with a brief comment on their purpose. +- Assertions use pattern matching: `assert {:ok, _} = ...`, not + `{:ok, val} = ...; assert val == ...`. +- For stream decoder raise tests: + `assert_raise RuntimeError, ~r/pattern/, fn -> ... end`. +- Do not couple decoder tests to encoder correctness — construct raw binaries + directly in test helpers when testing a decoder in isolation. +- Test file paths must mirror `lib/` paths exactly. + +## Dependencies + +| Dep | Purpose | +| -------------- | ----------------------------- | +| `:cbor` | CBOR encode/decode | +| `:typedstruct` | Typed struct DSL | +| `:varint` | Unsigned varint encode/decode | +| `:ex_doc` | Doc generation (dev only) | +| `:credo` | Static analysis (dev + test) | + +No Dialyzer setup. No property-based testing. Do not add new dependencies +without discussion — the dep surface is intentionally minimal. + +## Formatter + +`.formatter.exs` imports `:typedstruct` so `typedstruct do ... end` blocks +format correctly. Default line length (98) applies. Always run `mix format` +before committing. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d5b9136 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +All notable changes to atex will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + + +## [0.1.0] - 2026-04-08 + +Initial release, with CID, DRISL, and CAR support. + +[unreleased]: https://github.com/cometsh/elixir-dasl/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/cometsh/elixir-dasl/releases/tag/v0.1.0 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9053417 --- /dev/null +++ b/LICENSE @@ -0,0 +1,18 @@ +Copyright 2026 comet.sh + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the “Software”), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 5c01767..2a71f56 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,60 @@ -# DASL +# elixir-dasl -collection of things for https://dasl.ing/ +An Elixir implementation of [DASL](https://dasl.ing/) primitives. + +## Overview + +**DASL** (Decentralized Authenticated Structure Layer) is a family of +specifications for content-addressed data that is interoperable with the broader +IPFS/IPLD ecosystem while remaining minimal and self-contained. + +This library provides: + +- `DASL.CID`: — content identifiers, a compact, self-describing pointer to a + piece of data. +- `DASL.DRISL`: — deterministic CBOR serialization. +- `DASL.CAR`: — Content-Addressable aRchive encoding and decoding, as well as a + stream decoder. +- `DASL.CAR.DRISL`: — a higher-level CAR variant where block values are Elixir + terms rather than raw binaries, and are encoded/decoded via DRISL + transparently. + +## Quick start + +```elixir +# CIDs +cid = DASL.CID.compute("hello world") +DASL.CID.verify?(cid, "hello world") # => true +DASL.CID.encode(cid) # => "bafkrei..." + +# Round-trip a CID string +{:ok, cid} = DASL.CID.new("bafkreifzjut3te2nhyekklss27nh3k72ysco7y32koao5eei66wof36n5e") + +# DRISL encode/decode +{:ok, bin} = DASL.DRISL.encode(%{"key" => [1, 2, 3]}) +{:ok, term, ""} = DASL.DRISL.decode(bin) + +# Build and encode a CAR archive +{car, cid1} = DASL.CAR.add_block(%DASL.CAR{}, "block one") +{car, cid2} = DASL.CAR.add_block(car, "block two") +{:ok, car} = DASL.CAR.add_root(car, cid1) +{:ok, bin} = DASL.CAR.encode(car) + +# Decode it back +{:ok, car} = DASL.CAR.decode(bin) + +# Stream a large CAR file +File.stream!("large.car", 65_536) +|> DASL.CAR.stream_decode() +|> Enum.each(fn + {:header, _version, roots} -> IO.inspect(roots, label: "roots") + {:block, cid, _data} -> IO.inspect(cid, label: "block") +end) +``` ## Installation -If [available in Hex](https://hex.pm/docs/publish), the package can be installed -by adding `dasl` to your list of dependencies in `mix.exs`: +Get elixir-dasl from [hex.pm](https://hex.pm) by adding it to your `mix.exs`: ```elixir def deps do @@ -15,6 +64,8 @@ def deps do end ``` -Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) -and published on [HexDocs](https://hexdocs.pm). Once published, the docs can -be found at . +Documentation can be found on HexDocs at https://hexdocs.pm/dasl. + +--- + +This project is licensed under the [MIT License](./LICENSE). diff --git a/lib/dasl/car.ex b/lib/dasl/car.ex index 836c5e7..2546bf9 100644 --- a/lib/dasl/car.ex +++ b/lib/dasl/car.ex @@ -24,8 +24,8 @@ defmodule DASL.CAR do ## Options - * `:verify` — boolean, default `true`. When enabled, each block's raw data - is verified against its CID digest using `DASL.CID.verify?/2`. Returns + - `:verify` — boolean, default `true`. Verifies each block's raw data + against its CID digest using `DASL.CID.verify?/2`. Returns `{:error, :block, :cid_mismatch}` on failure. """ @@ -38,8 +38,8 @@ defmodule DASL.CAR do ## Options - * `:verify` — boolean, default `true`. When enabled, the block binary is - verified against its CID digest using `DASL.CID.verify?/2`. Returns + - `:verify` — boolean, default `true`. Verifies each block binary against + its CID digest using `DASL.CID.verify?/2`. Returns `{:error, :block, :cid_mismatch}` on failure. """ @@ -53,14 +53,14 @@ defmodule DASL.CAR do Each element of `chunk_stream` must be a binary of any size. Items are emitted as soon as a complete frame has been buffered: - * `{:header, version, roots}` — emitted once when the header is parsed - * `{:block, cid, data}` — emitted per block; `data` is the raw binary + - `{:header, version, roots}` — emitted once when the header is parsed + - `{:block, cid, data}` — emitted per block; `data` is the raw binary Raises on parse errors (invalid header, truncated stream, CID mismatch). ## Options - * `:verify` — boolean, default `true`. Verifies each block against its CID. + - `:verify` — boolean, default `true`. Verifies each block against its CID. ## Examples diff --git a/lib/dasl/car/drisl.ex b/lib/dasl/car/drisl.ex index f5ce832..a413647 100644 --- a/lib/dasl/car/drisl.ex +++ b/lib/dasl/car/drisl.ex @@ -26,7 +26,7 @@ defmodule DASL.CAR.DRISL do ## Options - * `:verify` — boolean, default `true`. Verifies each block's raw data + - `:verify` — boolean, default `true`. Verifies each block's raw data against its CID before DRISL decoding. Returns `{:error, :block, :cid_mismatch}` on failure. @@ -52,7 +52,7 @@ defmodule DASL.CAR.DRISL do ## Options - * `:verify` — boolean, default `true`. Verifies each DRISL-encoded block + - `:verify` — boolean, default `true`. Verifies each DRISL-encoded block binary against its CID before writing. Returns `{:error, :block, :cid_mismatch}` on failure. @@ -73,8 +73,8 @@ defmodule DASL.CAR.DRISL do Each element of `chunk_stream` must be a binary of any size. Items are emitted as soon as a complete frame has been buffered: - * `{:header, version, roots}` — emitted once when the header is parsed - * `{:block, cid, term}` — emitted per block; `term` is the DRISL-decoded + - `{:header, version, roots}` — emitted once when the header is parsed + - `{:block, cid, term}` — emitted per block; `term` is the DRISL-decoded Elixir value Raises on parse errors (invalid header, truncated stream, CID mismatch, @@ -82,7 +82,7 @@ defmodule DASL.CAR.DRISL do ## Options - * `:verify` — boolean, default `true`. Verifies each block against its CID + - `:verify` — boolean, default `true`. Verifies each block against its CID before DRISL decoding. ## Examples diff --git a/lib/dasl/car/stream_decoder.ex b/lib/dasl/car/stream_decoder.ex index fe0532f..aab2471 100644 --- a/lib/dasl/car/stream_decoder.ex +++ b/lib/dasl/car/stream_decoder.ex @@ -8,9 +8,9 @@ defmodule DASL.CAR.StreamDecoder do Emits the following elements in order: - * `{:header, version, roots}` — exactly once, as soon as the header frame + - `{:header, version, roots}` — exactly once, as soon as the header frame has been fully received - * `{:block, cid, data}` — once per block; `data` is the raw binary + - `{:block, cid, data}` — once per block; `data` is the raw binary Raises on any parse error (truncated stream, invalid header, CID mismatch, etc.). @@ -33,7 +33,7 @@ defmodule DASL.CAR.StreamDecoder do ## Options - * `:verify` — boolean, default `true`. Verifies each block's raw data + - `:verify` — boolean, default `true`. Verifies each block's raw data against its CID digest using `DASL.CID.verify?/2`. Raises on mismatch. ## Examples diff --git a/lib/dasl/cid.ex b/lib/dasl/cid.ex index 2ae61c1..68b0be3 100644 --- a/lib/dasl/cid.ex +++ b/lib/dasl/cid.ex @@ -1,6 +1,13 @@ defmodule DASL.CID do @moduledoc """ - Struct for DASL CIDs. + DASL content identifier (CID). + + A CID is a self-describing content address: it encodes a version, a codec + (`:raw` or `:drisl`), and a SHA-256 digest. CIDs can be round-tripped + through their canonical multibase string form, constructed by hashing + arbitrary data with `compute/2`, and verified against their content with + `verify?/2`. + Spec: https://dasl.ing/cid.html """ @@ -25,8 +32,6 @@ defmodule DASL.CID do @doc """ Parses a string-encoded CID into raw bytes. - Expects the `b` multibase prefix followed by a lowercase RFC 4648 base32-encoded payload. - ## Examples iex> DASL.CID.parse("bafkreifzjut3te2nhyekklss27nh3k72ysco7y32koao5eei66wof36n5e") @@ -54,6 +59,10 @@ defmodule DASL.CID do @doc """ Decodes a raw CID bytestring into its constituent fields. + Returns `{:ok, map}` on success, or `{:error, message}` if the bytes do not + conform to the CID spec (unsupported version, unknown codec, wrong hash + algorithm or size, truncated input). + ## Examples iex> bytes = <<1, 85, 18, 32, 185, 77, 39, 185, 147, 77, 62, 8, 165, 46, 82, 215, @@ -158,9 +167,7 @@ defmodule DASL.CID do do: "b" <> Base.encode32(bytes, case: :lower, padding: false) @doc """ - Constructs a `DASL.CID` from a CBOR tag (tag 42). - - CBOR-encoded CIDs have a leading null byte (`0x00`) prepended to the raw CID bytes. + Constructs a `DASL.CID` from a CBOR tag 42 value. ## Examples @@ -195,7 +202,7 @@ defmodule DASL.CID do def from_cbor(_), do: {:error, "invalid CBOR CID tag"} @doc """ - Converts a `DASL.CID` to a CBOR tag (tag 42) with the required leading null byte. + Converts a `DASL.CID` to a CBOR tag 42 value. ## Examples @@ -217,9 +224,7 @@ defmodule DASL.CID do do: %CBOR.Tag{tag: 42, value: %CBOR.Tag{tag: :bytes, value: <<0, bytes::binary>>}} @doc """ - Computes a CID for an arbitrary binary. - - Hashes `data` with SHA-256 and returns a `DASL.CID` with the given codec (defaults to `:raw`). + Computes a CID for an arbitrary binary, defaulting to the `:raw` codec. ## Examples @@ -289,7 +294,6 @@ defimpl String.Chars, for: DASL.CID do end defimpl Inspect, for: DASL.CID do - def inspect(cid, _opts) do cid = DASL.CID.encode(cid) ~s'DASL.CID.new("#{cid}")' diff --git a/lib/dasl/drisl.ex b/lib/dasl/drisl.ex index 06d9622..da7371d 100644 --- a/lib/dasl/drisl.ex +++ b/lib/dasl/drisl.ex @@ -11,8 +11,8 @@ defmodule DASL.DRISL do @doc """ Decodes a DRISL-encoded binary into an Elixir term. - CIDs encoded as CBOR tag 42 are decoded into `%DASL.CID{}` structs. - Returns `{:ok, term, rest}` on success, or `{:error, reason}` on failure. + CBOR tag 42 values are decoded into `%DASL.CID{}` structs. Returns + `{:ok, term, rest}` on success, or `{:error, reason}` on failure. ## Examples @@ -29,8 +29,6 @@ defmodule DASL.DRISL do @doc """ Encodes an Elixir term into a DRISL-compliant CBOR binary. - Map keys are sorted in bytewise-lexicographic order of their encoded form. - `%DASL.CID{}` values are encoded as CBOR tag 42 bytestrings. Returns `{:ok, binary}` on success, or `{:error, reason}` on failure. ## Examples diff --git a/lib/dasl/drisl/decoder.ex b/lib/dasl/drisl/decoder.ex index 908625b..aeb70ef 100644 --- a/lib/dasl/drisl/decoder.ex +++ b/lib/dasl/drisl/decoder.ex @@ -2,15 +2,8 @@ defmodule DASL.DRISL.Decoder do @moduledoc """ DRISL decoder. - Validates and decodes a CBOR binary according to the DRISL profile: - - - Only tag 42 (CIDs) is permitted; all other CBOR tags are rejected. - - Map keys must be strings. - - Simple values other than `true`, `false`, and `null` are rejected. - - Non-finite floats (infinity, negative infinity, NaN) are rejected. - - Half-precision (major 7, additional 25) and single-precision (major 7, additional 26) - float encodings are rejected; only 64-bit IEEE 754 is allowed. - - Indefinite-length items are rejected (enforced by the CBOR library). + Validates and decodes a CBOR binary according to the DRISL profile. See the + spec for the full set of constraints. Spec: https://dasl.ing/drisl.html """ @@ -18,18 +11,8 @@ defmodule DASL.DRISL.Decoder do @doc """ Decodes a DRISL-encoded binary into an Elixir term. - CIDs encoded as CBOR tag 42 are decoded into `%DASL.CID{}` structs. - - Returns `{:ok, term, rest}` on success, or `{:error, reason}` on failure. - `reason` is one of: - - `:half_precision_float` — half-precision float encoding found - - `:single_precision_float` — single-precision float encoding found - - `:cbor_decode_error` — CBOR is structurally invalid - - `:forbidden_tag` — a CBOR tag other than 42 was used - - `:non_string_map_key` — a map key is not a string - - `:forbidden_simple` — a simple value other than true/false/null - - `:forbidden_float` — a non-finite float (inf, -inf, NaN) - - `:invalid_cid` — tag-42 value does not contain a valid CID + CBOR tag 42 values are decoded into `%DASL.CID{}` structs. Returns + `{:ok, term, rest}` on success, or `{:error, reason}` on failure. ## Examples diff --git a/lib/dasl/drisl/encoder.ex b/lib/dasl/drisl/encoder.ex index 9d30cb6..0c6aa62 100644 --- a/lib/dasl/drisl/encoder.ex +++ b/lib/dasl/drisl/encoder.ex @@ -2,18 +2,8 @@ defmodule DASL.DRISL.Encoder do @moduledoc """ DRISL encoder. - Encodes Elixir terms into DRISL-compliant CBOR binary. DRISL is a strict - profile of CBOR with the following constraints enforced at encode time: - - - Map keys must be strings. - - Map keys are encoded in bytewise-lexicographic order of their CBOR encoding - (length-first canonical sort, as per RFC 7049 §3.9 and the DRISL spec). - - Floats are always encoded as 64-bit IEEE 754 (`0xfb`). Half-precision and - single-precision float encodings are never produced. - - Only `true`, `false`, and `nil` are valid simple values. - - `%DASL.CID{}` values are encoded as CBOR tag 42 bytestrings. - - No other CBOR tags are emitted. - - No indefinite-length encodings are produced. + Encodes Elixir terms into DRISL-compliant CBOR binary. See the spec for the + full set of constraints enforced at encode time. Spec: https://dasl.ing/drisl.html """ @@ -22,9 +12,6 @@ defmodule DASL.DRISL.Encoder do Encodes an Elixir term into a DRISL-compliant CBOR binary. Returns `{:ok, binary}` on success, or `{:error, reason}` on failure. - `reason` is one of: - - `:non_string_map_key` — a map key is not a string - - `:unsupported_type` — a value type not representable in DRISL ## Examples diff --git a/mix.exs b/mix.exs index e0a4224..05003c2 100644 --- a/mix.exs +++ b/mix.exs @@ -1,13 +1,21 @@ defmodule DASL.MixProject do use Mix.Project + @version "0.1.0" + @github "https://github.com/cometsh/elixir-dasl" + @tangled "https://tangled.org/@comet.sh/elixir-dasl" + def project do [ app: :dasl, - version: "0.1.0", + version: @version, elixir: "~> 1.18", start_permanent: Mix.env() == :prod, - deps: deps() + deps: deps(), + name: "dasl", + description: "An Elixir implementation of DASL primitives.", + package: package(), + docs: docs() ] end @@ -28,4 +36,24 @@ defmodule DASL.MixProject do {:credo, "~> 1.7", only: [:dev, :test], runtime: false} ] end + + defp package do + [ + licenses: ["MIT"], + links: %{"GitHub" => @github, "Tangled" => @tangled} + ] + end + + defp docs do + [ + extras: [ + "README.md": [title: "Overview"], + "CHANGELOG.md": [title: "Changelog"] + ], + main: "readme", + source_url: @github, + source_ref: "v#{@version}", + formatters: ["html"] + ] + end end