1package bundle23import (4 "archive/zip"5 "encoding/json"6 "fmt"7 "io"8 "os"9 "time"10)1112// MaxManifestSize bounds what a reader will read as a manifest. A manifest13// lists a volume's worlds and nothing per feature, so a megabyte is already14// generous; a larger one is some other file wearing the name.15const MaxManifestSize = 1 << 201617// Reader is an opened bundle. The archive stays open for the reader's18// lifetime, so serving an entry is a seek and a read against one held19// descriptor rather than a fresh open of a multi-hundred-megabyte file.20//21// A Reader is safe for concurrent use: entries are opened independently.22type Reader struct {23 // Manifest is what the bundle says about itself, validated at open.24 Manifest Manifest2526 // Path is where the bundle was opened from, empty for a reader opened27 // over bytes. It is a label and a tie-breaker, never an identity.28 Path string2930 zip *zip.Reader31 closer io.Closer32 entries map[string]*zip.File3334 // size and modTime are the file as it was opened, letting a rescan tell an35 // untouched file from one rewritten in place under the same name. Both are36 // zero for a reader opened over bytes.37 size int6438 modTime time.Time39}4041// Open reads the manifest of the bundle at path and refuses anything that is42// not a bundle this package understands.43//44// It does not check the manifest's promises against the archive's contents;45// that is [Reader.Validate], which costs a pass over every payload and is for46// producers and importers, not for every launch.47func Open(path string) (*Reader, error) {48 file, err := os.Open(path)49 if err != nil {50 return nil, fmt.Errorf("open %s: %w", path, err)51 }52 info, err := file.Stat()53 if err != nil {54 file.Close()55 return nil, fmt.Errorf("stat %s: %w", path, err)56 }57 reader, err := NewReader(file, info.Size())58 if err != nil {59 file.Close()60 return nil, fmt.Errorf("%s: %w", path, err)61 }62 reader.Path = path63 reader.closer = file64 reader.size = info.Size()65 reader.modTime = info.ModTime()66 return reader, nil67}6869// NewReader opens a bundle over bytes already in hand: a mapped file, an70// in-memory buffer, an object store's ranged reader. It is the form that71// keeps this package free of any particular filesystem; [Open] is the72// convenience over it.73//74// The caller keeps ownership of ra: [Reader.Close] on a reader made this way75// releases nothing.76func NewReader(ra io.ReaderAt, size int64) (*Reader, error) {77 archive, err := zip.NewReader(ra, size)78 if err != nil {79 return nil, fmt.Errorf("read archive: %w", err)80 }81 reader := &Reader{zip: archive, entries: make(map[string]*zip.File, len(archive.File))}82 for _, file := range archive.File {83 reader.entries[file.Name] = file84 }85 if err := reader.readManifest(); err != nil {86 return nil, err87 }88 return reader, nil89}9091func (r *Reader) readManifest() error {92 entry, held := r.entries[ManifestName]93 if !held {94 return fmt.Errorf("archive carries no %s, so it is not a bundle", ManifestName)95 }96 if entry.UncompressedSize64 > MaxManifestSize {97 return fmt.Errorf("manifest is implausibly large")98 }99 source, err := entry.Open()100 if err != nil {101 return fmt.Errorf("open manifest: %w", err)102 }103 data, err := io.ReadAll(io.LimitReader(source, MaxManifestSize))104 source.Close()105 if err != nil {106 return fmt.Errorf("read manifest: %w", err)107 }108 if err := json.Unmarshal(data, &r.Manifest); err != nil {109 return fmt.Errorf("decode manifest: %w", err)110 }111 if err := r.Manifest.Validate(); err != nil {112 return err113 }114 return nil115}116117// Has reports whether the bundle holds an entry by this name.118func (r *Reader) Has(name string) bool {119 _, held := r.entries[name]120 return held121}122123// Names lists every entry in the archive's own order, which is the order a124// producer wrote them: the manifest first, then payloads, tiles, and icons.125func (r *Reader) Names() []string {126 out := make([]string, 0, len(r.zip.File))127 for _, file := range r.zip.File {128 out = append(out, file.Name)129 }130 return out131}132133// OpenEntry opens one entry for reading and reports its uncompressed size, so134// a server can announce a length before streaming.135func (r *Reader) OpenEntry(name string) (io.ReadCloser, int64, error) {136 entry, held := r.entries[name]137 if !held {138 return nil, 0, fmt.Errorf("bundle holds no %s", name)139 }140 source, err := entry.Open()141 if err != nil {142 return nil, 0, fmt.Errorf("open %s: %w", name, err)143 }144 return source, int64(entry.UncompressedSize64), nil145}146147// ReadEntry reads one entry whole.148func (r *Reader) ReadEntry(name string) ([]byte, error) {149 source, _, err := r.OpenEntry(name)150 if err != nil {151 return nil, err152 }153 defer source.Close()154 data, err := io.ReadAll(source)155 if err != nil {156 return nil, fmt.Errorf("read %s: %w", name, err)157 }158 return data, nil159}160161// Stored reports whether an entry is held uncompressed. Tiles are, which is162// what lets a server answer a byte range out of the archive without inflating163// anything.164func (r *Reader) Stored(name string) bool {165 entry, held := r.entries[name]166 return held && entry.Method == zip.Store167}168169// Locations decodes one world's packed point features into a borrowing view.170// The view holds the payload bytes, so it outlives neither them nor a caller171// that discards them.172func (r *Reader) Locations(worldSlug string) (*Packed, error) {173 data, err := r.ReadEntry(WorldEntryName(worldSlug, PackedSuffix))174 if err != nil {175 return nil, fmt.Errorf("world %s: %w", worldSlug, err)176 }177 packed, err := OpenPacked(data)178 if err != nil {179 return nil, fmt.Errorf("world %s: %w", worldSlug, err)180 }181 return packed, nil182}183184// Descriptor is the bundle as the registry fold sees it: identity and185// version, with no open file behind it.186func (r *Reader) Descriptor() Descriptor {187 return DescriptorOf(r.Path, r.Manifest, r.size)188}189190// Unchanged reports whether the file this reader was opened from still has191// the size and modification time it had at open. A rescan uses it to carry an192// already-open bundle across rather than reopening a file that has not moved.193// A reader opened over bytes is never unchanged, because nothing here knows194// what those bytes were.195func (r *Reader) Unchanged(size int64, modTime time.Time) bool {196 return r.closer != nil && r.size == size && r.modTime.Equal(modTime)197}198199// Close releases the archive. Entries opened before must not be read after.200// Closing a reader made by [NewReader] does nothing, since it owns nothing.201func (r *Reader) Close() error {202 if r.closer == nil {203 return nil204 }205 return r.closer.Close()206}