package declaration import ( "crypto/sha256" "encoding/base64" "encoding/hex" "fmt" "regexp" ) // The three names of a file travelling by digest (novox/hq ADR 0486 §1, §2, §5), shared by the node-engine that // reads them and the controller that writes them. const ( // FileReferenceThreshold is the size above which a file travels by digest: a file of this many bytes or fewer // stays in the declaration as it is. FileReferenceThreshold = 64 * 1024 // FileBucketPrefix starts the name of a node's object bucket, FileBucketFor(node), which holds the contents of the // files of that node's declaration, each under the hex of its sha256 digest (ObjectName). One bucket per node, // so a grant names it and a literal `*` is never needed (ADR 0486 §2, changed by ADR 0488 §1 and §2). The controller alone writes it; the // node's engine reads an object by name. FileBucketPrefix = "declaration-files-" // FileStreamPrefix starts the name of the stream behind such a bucket, which the bus's grants name. FileStreamPrefix = "OBJ_" // FileReferencesCapability is what a node-engine reports when it reads a file given as a digest and size. FileReferencesCapability = "file-references" ) var nodeToken = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) // FileBucketFor is the name of the object bucket of a node's files, "declaration-files-" (novox/hq ADR 0488 // §4, which replaces the single bucket constant of issue 1694). The node name must be a name the bus accepts in a // bucket and in a subject token: letters, digits, '_' and '-', so no '.', '*', '>' or space. func FileBucketFor(node string) (string, error) { if !nodeToken.MatchString(node) { return "", fmt.Errorf("node %q; a node's bucket is named by letters, digits, '_' and '-' only", node) } return FileBucketPrefix + node, nil } // FileStreamFor is the name of the stream behind a node's bucket, "OBJ_" and the bucket's name. func FileStreamFor(node string) (string, error) { b, err := FileBucketFor(node) if err != nil { return "", err } return FileStreamPrefix + b, nil } var digestForm = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) // DigestOf is the digest a reference names for a content: "sha256:<64 lower-case hex>". func DigestOf(content []byte) string { sum := sha256.Sum256(content) return "sha256:" + hex.EncodeToString(sum[:]) } // ObjectName is the name of the object holding the content a digest names: the hex without its "sha256:" prefix. func ObjectName(digest string) (string, error) { if !digestForm.MatchString(digest) { return "", fmt.Errorf("digest %q is not sha256:<64 lower-case hex characters>", digest) } return digest[len("sha256:"):], nil } // IsReference reports whether the file gives its content as a digest and size instead of carrying it. func (f *File) IsReference() bool { return f.Digest != "" } // checkReference judges the digest and size, and what a reference may not be given with. func (f *File) checkReference(where string) []string { if f.Digest == "" && f.Size == 0 { return nil } var problems []string if !digestForm.MatchString(f.Digest) { problems = append(problems, fmt.Sprintf("%s: digest %q; a file by reference names its content as "+ "sha256:<64 lower-case hex characters>", where, f.Digest)) } if f.Size <= 0 { problems = append(problems, fmt.Sprintf("%s: size %d; a file by reference states its size in bytes, "+ "more than zero", where, f.Size)) } if f.Content != "" || f.Bytes != "" || f.Sealed != "" || f.VaultSealed != nil || len(f.Secrets) > 0 { problems = append(problems, where+": a file by reference carries its content in the object its digest "+ "names, so it says no content, bytes, sealed, vault-sealed or secrets") } if f.Into != "" { problems = append(problems, where+": a file by reference is written whole; a file written into says its "+ "part in content") } return problems } // Body is the bytes the file's content stands for: its content, or its bytes decoded. func (f *File) Body() ([]byte, error) { if f.Bytes == "" { return []byte(f.Content), nil } b, err := base64.StdEncoding.DecodeString(f.Bytes) if err != nil { return nil, fmt.Errorf("file %s: bytes are not base64: %w", f.ID, err) } return b, nil } // TravelsByDigest reports whether the controller may put this file's content in the bucket: it is plain content // or bytes (not sealed, not filled with secrets, not written into another file) of more than FileReferenceThreshold // bytes. func (f *File) TravelsByDigest() bool { if f.IsReference() || f.Sealed != "" || f.VaultSealed != nil || len(f.Secrets) > 0 || f.Into != "" { return false } if f.Bytes != "" { // DecodedLen counts padding, so the exact length is taken off the decoded bytes. body, err := f.Body() return err == nil && len(body) > FileReferenceThreshold } return len(f.Content) > FileReferenceThreshold } // AsReference is the file as it is sent to an engine that reports FileReferencesCapability: a copy with the digest // and size in place of the content, and the content the controller puts into the bucket under ObjectName(digest). // A file that does not travel by digest is refused, not passed through, so a caller cannot send a small file empty. func (f *File) AsReference() (File, []byte, error) { if !f.TravelsByDigest() { return File{}, nil, fmt.Errorf("file %s does not travel by digest", f.ID) } body, err := f.Body() if err != nil { return File{}, nil, err } ref := *f ref.Content, ref.Bytes = "", "" ref.Digest, ref.Size = DigestOf(body), int64(len(body)) return ref, body, nil } // Verify checks what was fetched against the digest and size the file names; the node-engine writes the file only // after it returns nil. A mismatch is the apply's failure, never a partial apply (ADR 0486 §1). func (f *File) Verify(body []byte) error { if !f.IsReference() { return fmt.Errorf("file %s is not given by reference", f.ID) } if int64(len(body)) != f.Size { return fmt.Errorf("file %s: the object is %d bytes and the declaration says %d", f.ID, len(body), f.Size) } if got := DigestOf(body); got != f.Digest { return fmt.Errorf("file %s: the object hashes to %s and the declaration names %s", f.ID, got, f.Digest) } return nil }