canonical

package
v0.1.0-rc.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 12, 2026 License: Apache-2.0 Imports: 20 Imported by: 0

Documentation

Overview

Package canonical produces the exact bytes a DevProof subject commits to: path normalization, tree records, JSON, tar, and gzip.

Everything here is a compatibility surface. These functions may not consult a clock, the environment, the working directory, randomness, or filesystem enumeration order (DP-012); .golangci.yaml enforces that with a depguard rule rather than trusting the convention.

Index

Constants

View Source
const DigestSize = bundle.DigestSize

DigestSize is the length of a SHA-256 digest in bytes.

View Source
const GzipCompressionLevel = 9

GzipCompressionLevel is the DEFLATE level format v1 compresses at.

View Source
const MaxFileSize = int64(1)<<33 - 1

MaxFileSize is the largest file format v1 can represent: eleven octal digits in the 12-byte USTAR size field, one byte short of 8 GiB.

Files larger than this are rejected rather than described with a PAX size record. Two reasons. The rule for when PAX appears collapses to a single case — a path too long for the name field — so there is one thing to specify and one thing to verify. And a PAX size path could not be meaningfully tested without an 8 GiB fixture, which would leave an untested branch in the code that decides artifact identity.

Raising this is a format-version decision.

View Source
const Separator = "/"

Separator is the only path separator a canonical path may contain. Native separators are the source adapter's problem: it splits on them and rejoins with this, so that a literal backslash in a POSIX filename never silently becomes a directory boundary.

Variables

This section is empty.

Functions

func BuildConfig

func BuildConfig(records []FileRecord) (*bundle.Config, error)

BuildConfig produces the config blob describing records.

records must already be sorted and validated; WriteTreeRecords is called to derive the tree digest, so an unsorted or malformed set fails here rather than producing a config that describes a tree nobody can reproduce.

func CanonicalizeJSON

func CanonicalizeJSON(raw []byte) ([]byte, error)

CanonicalizeJSON rewrites already-valid JSON into its RFC 8785 form.

func MarshalJSON

func MarshalJSON(v any) ([]byte, error)

MarshalJSON encodes v as RFC 8785 canonical JSON.

Canonical JSON is what lets a lock, a config, or a policy be identified by digest: two encoders that agree on the value must agree on the bytes, so key order, whitespace, and escaping cannot carry information.

Numbers are restricted to integers. RFC 8785 specifies ECMAScript double formatting for the general case, which is a large and subtle surface, and no DevProof document contains a non-integer number. A float here is therefore a bug in a caller's types rather than input to accommodate, and is reported as one. Admitting floats later is a format decision.

func ParseManifest

func ParseManifest(data []byte) (*artifact.Manifest, error)

ParseManifest decodes and validates a subject manifest.

Decoding is strict. An unknown field in a manifest is either a newer format this build should refuse, or an attempt to smuggle something past a reader that ignores what it does not recognize; neither is a reason to continue.

func WriteTreeRecords

func WriteTreeRecords(w io.Writer, records []FileRecord) error

WriteTreeRecords writes the normative v1 tree record stream to w.

The encoding is fixed by docs/bundle-format.md:

"devproof-tree-v1\x00"
uint64be  record count
per record, in canonical path order:
  uint32be  path byte length
  bytes     canonical UTF-8 path
  uint32be  normalized mode
  uint64be  file byte length
  [32]byte  raw SHA-256 content digest

There is no padding, delimiter, or terminator beyond those fields.

Types

type ContentSource

type ContentSource interface {
	Open(ctx context.Context, path Path) (io.ReadCloser, error)
}

ContentSource supplies the bytes for a canonical file record.

Packaging reads only through this interface, and only for paths that appear in the inventory it was given. That is what keeps the packager away from a live filesystem: a resolver hands it a frozen snapshot, and there is no API here through which it could reach anything else.

type Digest

type Digest = bundle.Digest

Digest and its helpers live in bundle: format v1 mandates SHA-256, which makes a digest a format concept rather than an encoding detail. They are aliased here so that this package's own API reads without a qualifier.

func DigestOf

func DigestOf(b []byte) Digest

DigestOf returns the SHA-256 of b.

func EncodeConfig

func EncodeConfig(cfg *bundle.Config) (Digest, []byte, error)

EncodeConfig renders cfg as canonical JSON and returns the bytes with their digest.

func EncodeManifest

func EncodeManifest(m *artifact.Manifest) (Digest, []byte, error)

EncodeManifest renders a subject manifest as canonical JSON and returns the bytes with their digest.

That digest is the bundle's identity, so the manifest is validated before it is encoded: minting a digest for a structurally invalid subject would give a name to something no consumer can verify.

func JSONDigest

func JSONDigest(v any) (Digest, []byte, error)

JSONDigest returns the SHA-256 of v's canonical JSON encoding, along with those bytes. Callers that persist a document need both: the digest identifies it and the bytes are what must be written, and recomputing either separately invites the two to disagree.

func ParseDigest

func ParseDigest(s string) (Digest, error)

ParseDigest accepts the OCI "sha256:<64 lowercase hex>" form.

func TreeDigest

func TreeDigest(records []FileRecord) (Digest, error)

TreeDigest returns the v1 tree digest for records.

It hashes incrementally rather than materializing the record stream: a bundle at the default file limit would otherwise build a buffer far larger than the inventory it describes.

func VerifyConfigTreeDigest

func VerifyConfigTreeDigest(cfg *bundle.Config) (Digest, error)

VerifyConfigTreeDigest recomputes the tree digest from a config's own inventory and compares it against the digest the config states.

The two can disagree only if the config was tampered with, so a mismatch is reported as such rather than as a generic validation failure.

type FileRecord

type FileRecord struct {
	// Path is the canonical bundle-relative path.
	Path Path
	// Mode is the normalized mode: bundle.ModeFile or bundle.ModeExecutable.
	Mode uint32
	// Size is the file's length in bytes.
	Size int64
	// Digest is the SHA-256 of the file's exact content bytes.
	Digest Digest
}

FileRecord is one entry of the canonical inventory: everything about a file that contributes to identity, and nothing that does not.

Ownership, timestamps, and link targets are absent by construction rather than normalized away later, so there is no code path where one could reach the tree digest.

func ConfigRecords

func ConfigRecords(cfg *bundle.Config) ([]FileRecord, error)

ConfigRecords converts a validated config back into file records.

This is the verification direction: a config fetched from a registry is turned into records so its tree digest can be recomputed and compared against the one the config claims.

type GzipWriter

type GzipWriter struct {
	// contains filtered or unexported fields
}

GzipWriter produces the canonical v1 gzip encoding of a stream.

The container is written here rather than by compress/gzip because that package chooses some header bytes itself, and those choices are not part of its compatibility promise. The compressed stream comes from the frozen encoder in the deflate subpackage (DP-016). Between them, every byte of the layer is pinned.

func NewGzipWriter

func NewGzipWriter(w io.Writer) (*GzipWriter, error)

NewGzipWriter returns a writer that emits the canonical v1 gzip encoding to w. The caller must call Close to flush the final block and the trailer.

func (*GzipWriter) Close

func (g *GzipWriter) Close() error

Close flushes the final DEFLATE block and writes the gzip trailer. It is idempotent, and reports the first error the writer saw.

func (*GzipWriter) Write

func (g *GzipWriter) Write(p []byte) (int, error)

type Path

type Path string

Path is a validated, normalized, relative bundle path. Its zero value is not a valid path; obtain one from NormalizePath.

func NormalizePath

func NormalizePath(p string, lim PathLimits) (Path, error)

NormalizePath validates p and returns its canonical form.

Normalization is NFC only. No segment is ever removed or resolved: a "." or ".." is rejected rather than collapsed, because collapsing would let a source describe one path and a consumer materialize another.

func (Path) FoldKey

func (p Path) FoldKey() string

FoldKey returns p's case-insensitive collision key.

Simple folding, matching what APFS, HFS+, and NTFS actually merge. Full folding would additionally collapse "ß" onto "ss", which no filesystem does, and would reject bundles that expand cleanly everywhere (DP-017).

func (Path) Parents

func (p Path) Parents() []string

Parents returns p's required parent directories, shallowest first. A path with no separator has none.

func (Path) String

func (p Path) String() string

type PathLimits

type PathLimits struct {
	// MaxBytes bounds the whole path's UTF-8 length.
	MaxBytes int
	// MaxSegmentBytes bounds one segment's UTF-8 length.
	MaxSegmentBytes int
	// MaxDepth bounds the number of segments.
	MaxDepth int
}

PathLimits bounds a canonical path.

It is a local struct rather than bundle.Limits so that bundle can depend on canonical for its inventory types without a cycle. Callers project the three relevant fields across.

type PathSet

type PathSet struct {
	// contains filtered or unexported fields
}

PathSet accumulates canonical paths and rejects any tree a consumer could materialize two different ways.

Three shapes are refused, all for the same reason: the result would depend on the extractor rather than on the bundle.

  • Two files at one path, or at paths that fold together. Equal bytes do not help; ownership would still be ambiguous (DP-011).
  • A file whose path is a directory in another entry, in either order.
  • Any of the above across case, since a case-insensitive filesystem merges them on arrival.

The zero value is ready to use.

func (*PathSet) Add

func (s *PathSet) Add(p Path) error

Add records p, deriving its parent directories.

Callers that need a deterministic *message* when a tree has several collisions should add paths in sorted order. The failure itself is order-independent: a colliding tree is rejected whatever order it arrives in, which is the property DP-011 requires.

func (*PathSet) Directories

func (s *PathSet) Directories() []string

Directories returns the derived parent directories sorted by canonical UTF-8 bytes, which places every parent before its children.

func (*PathSet) Files

func (s *PathSet) Files() []Path

Files returns every added path sorted by canonical UTF-8 bytes, which is the order the tree digest and the tar stream require.

func (*PathSet) HasFile

func (s *PathSet) HasFile(p Path) bool

HasFile reports whether p was added as a file.

func (*PathSet) Len

func (s *PathSet) Len() int

Len reports how many files were added.

type Subject

type Subject struct {
	// Manifest is the OCI subject. ManifestDigest is the bundle's identity.
	Manifest       *artifact.Manifest
	ManifestBytes  []byte
	ManifestDigest Digest

	// Config is the inventory blob.
	Config       *bundle.Config
	ConfigBytes  []byte
	ConfigDigest Digest

	// LayerDigest and LayerSize describe the compressed archive that was
	// written to the packager's output.
	LayerDigest Digest
	LayerSize   int64

	// TreeDigest identifies the payload independently of how it was encoded.
	// Two bundles with the same tree digest hold the same files even if a
	// future format encodes them differently.
	TreeDigest Digest
}

Subject is a fully encoded bundle.

The config and manifest are carried as bytes because both are small by construction and a caller that recomputed either would invite the bytes and the digest to disagree. The layer is not: it is streamed to a writer during packaging and identified here by digest and size only.

func Package

func Package(ctx context.Context, records []FileRecord, src ContentSource, layerOut io.Writer) (*Subject, error)

Package encodes records into a complete bundle subject, streaming the compressed layer to layerOut.

The work happens in one pass over the content so that hashing, archiving, and compressing all see the same bytes. Anything else would leave a window in which the layer describes content the config never saw.

func VerifySubject

func VerifySubject(manifestBytes, configBytes []byte, layerSize int64, layerDigest Digest) (*Subject, error)

VerifySubject re-derives a subject's identity from its own blobs.

It is the read-side counterpart of Package: given the bytes a registry served, it establishes that the manifest, config, and layer describe one consistent artifact before any of it is trusted or written to disk.

func (*Subject) ConfigDescriptor

func (s *Subject) ConfigDescriptor() artifact.Descriptor

ConfigDescriptor returns the descriptor naming the config blob.

func (*Subject) Descriptor

func (s *Subject) Descriptor() artifact.Descriptor

Descriptor returns the descriptor naming this subject.

func (*Subject) LayerDescriptor

func (s *Subject) LayerDescriptor() artifact.Descriptor

LayerDescriptor returns the descriptor naming the compressed layer.

type TarWriter

type TarWriter struct {
	// contains filtered or unexported fields
}

TarWriter emits the canonical v1 tar stream.

It is written by hand rather than with archive/tar because DP-019 fixes every header byte, including PAX record naming, ordering, and numeric encoding. archive/tar makes several of those choices itself, and none of them are covered by its compatibility promise — the same coupling DP-016 removed for the compressor.

The writer does not sort. It emits what it is given, in the order given, and rejects anything that would produce a stream a consumer could materialize two ways.

func NewTarWriter

func NewTarWriter(w io.Writer) *TarWriter

NewTarWriter returns a writer emitting the canonical v1 tar stream to w.

func (*TarWriter) Close

func (t *TarWriter) Close() error

Close writes the two zero blocks that terminate the archive. Nothing may follow them.

func (*TarWriter) WriteDirectory

func (t *TarWriter) WriteDirectory(path string) error

WriteDirectory emits a directory entry.

Directories are derived from file paths, never carried from a source, so this takes a path and nothing else: there is no mode or ownership to get wrong.

func (*TarWriter) WriteFile

func (t *TarWriter) WriteFile(rec FileRecord, content io.Reader) error

WriteFile emits a file entry and copies its content, hashing as it goes.

The observed digest and size are checked against rec before the entry is considered written. Hashing here rather than trusting the inventory is what closes the time-of-check gap: content that changed between snapshot and packaging fails the build instead of producing a bundle whose layer disagrees with its own config.

Directories

Path Synopsis
Package deflate is a frozen copy of the Go standard library's DEFLATE encoder.
Package deflate is a frozen copy of the Go standard library's DEFLATE encoder.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL