Tama.Dex.Cassette (tama_dex v0.1.0)

Copy Markdown View Source

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

build_error()

@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()]}]}

manifest()

@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()}
  ]
}

verify_error()

@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

build(documents, path, opts)

@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 MiB verify/2 accepts.

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.

verify(path, opts \\ [])

@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).