1package bundle23import (4 "crypto/sha256"5 "encoding/hex"6 "sort"7 "strings"8)910// ShortStampLength is how much of a stamp URLs, file names, and logs carry:11// long enough that two builds of a volume will not collide, short enough to12// read past.13const ShortStampLength = 121415// Stamp accumulates a content fingerprint for a bundle without requiring the16// bundle's bytes. A producer feeds it a hash per named part -- payloads it17// just built, pyramid stamps its tile pipeline already computed -- and the sum18// is stable however the parts arrived. Equal stamps mean a rebuild would write19// the same bundle, which is what lets an unchanged volume be skipped.20//21// The sum is order-independent by construction: parts are sorted before they22// are hashed, so two producers that visit a volume's worlds in different23// orders still agree. The zero Stamp is ready to use.24type Stamp struct {25 parts []string26}2728// Add records one named part. Order does not matter; the sum sorts.29//30// The name matters as much as the hash: it is what distinguishes two parts31// that happen to hold the same bytes, and what makes a renamed part a32// different bundle.33//34// A part name must hold no space and no newline. The two are the record35// separators of the summed form, so a name carrying either makes the sum36// ambiguous -- "a b" with hash "c" sums as "a" with hash "b c" does. Archive37// entry names have never held one, and the constraint is kept rather than38// coded around because widening the separator would restamp every bundle in39// every library.40func (s *Stamp) Add(name, hash string) {41 s.parts = append(s.parts, name+" "+hash)42}4344// Sum returns the fingerprint of everything added: the sorted "name hash"45// lines joined by newlines, hashed with SHA-256, in lowercase hex. Sum does46// not consume the parts, so a producer may add more and sum again.47func (s *Stamp) Sum() string {48 sorted := make([]string, len(s.parts))49 copy(sorted, s.parts)50 sort.Strings(sorted)51 digest := sha256.Sum256([]byte(strings.Join(sorted, "\n")))52 return hex.EncodeToString(digest[:])53}5455// Parts lists the named parts as they will be hashed, sorted. It exists so a56// producer can report what a stamp was computed over when two builds57// unexpectedly differ.58func (s *Stamp) Parts() []string {59 sorted := make([]string, len(s.parts))60 copy(sorted, s.parts)61 sort.Strings(sorted)62 return sorted63}6465// HashBytes fingerprints one part for [Stamp.Add]: SHA-256, lowercase hex.66func HashBytes(data []byte) string {67 digest := sha256.Sum256(data)68 return hex.EncodeToString(digest[:])69}7071// ShortStamp is the stamp as URLs, file names, and logs carry it.72func ShortStamp(stamp string) string {73 if len(stamp) <= ShortStampLength {74 return stamp75 }76 return stamp[:ShortStampLength]77}7879// CaptureDay is the day part of a timestamp, as the file name spells it: the80// first eight digits, which for an RFC 3339 time is YYYYMMDD.81//82// The rule is digit-counting rather than time parsing so that a name can be83// derived from a manifest that has not been validated yet. A timestamp84// carrying fewer than eight digits yields the digits it has, and one with none85// yields the empty string, in which case the file name simply goes without.86func CaptureDay(createdAt string) string {87 var day strings.Builder88 for _, r := range createdAt {89 if r >= '0' && r <= '9' {90 day.WriteRune(r)91 if day.Len() == 8 {92 break93 }94 }95 }96 return day.String()97}9899// VersionedFileName is the name a bundle carries in a registry directory:100// <slug>-<YYYYMMDD>-<stamp12>.atlas -- the volume, the day its data was101// captured, and enough of the stamp to tell two builds of the same capture102// apart.103//104// Builds of a volume sit side by side under these names, and the fold is what105// serves the right one. The name is for people and for cheap existence106// checks, never for ordering.107func VersionedFileName(m Manifest) string {108 name := m.Volume.Slug109 if day := CaptureDay(m.Version.CreatedAt); day != "" {110 name += "-" + day111 }112 return name + "-" + ShortStamp(m.Version.Stamp) + ".atlas"113}