Builds and verifies cassettes: portable, prebuilt index files in the
tama-cassette/1 format, each holding one cassette.
A producer's CI builds a cassette from its profile documents, usually with
mix tama.dex.cassette, and publishes it with its SHA-256. A host
installs it with Tama.Dex.import/3 from a local file it supplies.
tama-dex never downloads cassettes.
A cassette holds the index's storage tables for its one cassette, a manifest (format, cassette name and version, tama-dex version, index schema version, SQLite version, profile and content digest) and each source's canonical document with its digest. It stores no wall-clock time.
Identity
The content digest covers the format, the cassette name and version, the index schema version and each source's document digest. Building the same documents again gives the same content digest, even where SQLite writes different bytes, so the content digest identifies a cassette and the file SHA-256 pins one file.
Trust
verify/2 treats a cassette as untrusted. Its prebuilt rows are copied
into an index only when the host pins the file's SHA-256 (see
Tama.Dex.import/3). Otherwise the index is rebuilt from the embedded
documents after their digests and profile validity are checked.
Summary
Types
Why a cassette was rejected. Names and sources read from the file are bounded.
Functions
Builds a cassette file at path from profile documents.
Verifies a cassette file without installing it.
Types
@type build_error() :: :no_documents | :too_large | {:invalid_document, non_neg_integer(), [Tama.Dex.Profile.Issue.t()]} | {:duplicate_source, String.t()} | {:duplicate_operation, [%{source: String.t(), operation_ids: [String.t()]}]}
@type manifest() :: %{ format: String.t(), name: String.t(), version: String.t(), tama_dex_version: String.t(), index_schema_version: integer(), sqlite_version: String.t(), profile: String.t(), content_digest: String.t(), sha256: String.t(), sources: [ %{source: String.t(), digest: String.t(), operations: non_neg_integer()} ] }
@type verify_error() :: {:file, File.posix()} | :too_large | :sha256_mismatch | :not_a_cassette | {:unexpected_schema, [String.t()]} | :invalid_manifest | {:document_digest_mismatch, String.t()} | {:invalid_document, String.t(), [Tama.Dex.Profile.Issue.t()]} | {:duplicate_operation, [map()]} | :content_digest_mismatch
Why a cassette was rejected. Names and sources read from the file are bounded.
{:file, posix}: the file can't be read.:too_large: over 64 MiB, checked before the file is opened.:sha256_mismatch: the file doesn't match the host's pin.:not_a_cassette: not a SQLite file in rollback-journal mode, or corrupt.{:unexpected_schema, names}: triggers, views, or tables and indexes other than the expected ones, checked before any row is read.:invalid_manifest: a missing or malformed manifest, or no documents.{:document_digest_mismatch, source}: an embedded document doesn't match its digest.{:invalid_document, source, issues}: an embedded document isn't a valid profile document.{:duplicate_operation, conflicts}: sources sharing operation IDs.:content_digest_mismatch: the manifest's content digest is wrong.
Functions
@spec build([term()], Path.t(), keyword()) :: {:ok, map()} | {:error, build_error()}
Builds a cassette file at path from profile documents.
Every document is validated first, and sources or operation IDs repeated
within the cassette are rejected, before anything is written. The file is
written to a temporary file in the same directory, compacted with
VACUUM and renamed into place, replacing any file at path. A file
larger than verify/2 accepts is discarded instead, leaving path
untouched.
Options
:name(required): the cassette name, matching^[a-z][a-z0-9_]{0,63}$.:version(required): the cassette version, at most 64 bytes, usually the producer's release version.:max_bytes: a lower size limit than the 64 MiBverify/2accepts.
Returns {:ok, %{path, cassette, version, digest, sha256, sources}},
where digest is the content digest, sha256 the file's SHA-256, and
each source has source and operations (a count). Fails with
{:error, :no_documents} for an empty list,
{:error, {:invalid_document, position, issues}} (position counts from
0), {:error, {:duplicate_source, source}},
{:error, {:duplicate_operation, conflicts}}, or {:error, :too_large}
when the file would be over the size limit. Raises ArgumentError for
a missing or invalid name, version or path.
@spec verify(Path.t(), keyword()) :: {:ok, manifest()} | {:error, verify_error()}
Verifies a cassette file without installing it.
Checks, in order: the size (64 MiB at most) before opening, the file
SHA-256 against :sha256 when given, the SQLite header, the schema
objects (no triggers or views, and exactly the expected tables and
indexes), the database's integrity, the manifest, every embedded
document's digest and profile validity, and the content digest. The file
is read once, into memory, and every check reads that copy, so a file
replaced during verification can't pass with other contents. SQLite opens
the copy as a private in-memory database, with extension loading off and
queries only.
Options
:sha256: the expected lowercase hex SHA-256 of the file.:max_bytes: a lower size limit.
Returns {:ok, manifest}, with the file's sha256 and its sources,
or {:error, reason} (see verify_error/0).