clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

main
atlas / format / bundle / write.go
write.go 3.8K
1package bundle23import (4 "archive/zip"5 "encoding/json"6 "fmt"7 "io"8 "strings"9)1011// Writer builds a bundle around a manifest it validates up front, so a12// producer cannot finish writing an archive a reader would refuse to open.13type Writer struct {14 zip *zip.Writer15 names map[string]bool16}1718// NewWriter starts a bundle on w. The manifest is written first, so a listing19// of the archive leads with what it is.20//21// The manifest must already carry its stamp and creation time: the name a22// bundle is written under is derived from them, and a producer that has not23// decided them has not finished composing.24func NewWriter(w io.Writer, manifest Manifest) (*Writer, error) {25 if err := manifest.Validate(); err != nil {26 return nil, fmt.Errorf("manifest: %w", err)27 }28 data, err := MarshalManifest(manifest)29 if err != nil {30 return nil, err31 }32 out := &Writer{zip: zip.NewWriter(w), names: map[string]bool{ManifestName: true}}33 entry, err := out.zip.CreateHeader(&zip.FileHeader{Name: ManifestName, Method: zip.Deflate})34 if err != nil {35 return nil, fmt.Errorf("create %s: %w", ManifestName, err)36 }37 if _, err := entry.Write(data); err != nil {38 return nil, fmt.Errorf("write %s: %w", ManifestName, err)39 }40 return out, nil41}4243// MarshalManifest encodes a manifest exactly as the container carries it.44// Producers stamp over these bytes, so this is the one encoding of a manifest45// that counts: encoding/json's own output, HTML escaping and all, with no46// indentation and no trailing newline.47func MarshalManifest(m Manifest) ([]byte, error) {48 data, err := json.Marshal(m)49 if err != nil {50 return nil, fmt.Errorf("marshal manifest: %w", err)51 }52 return data, nil53}5455// AddStored copies r into the bundle uncompressed. Tiles and packed locations56// go in this way: they are already dense, and a stored entry is served as a57// straight byte range where a deflated one is inflated on every read.58func (w *Writer) AddStored(name string, r io.Reader) error {59 entry, err := w.create(name, zip.Store)60 if err != nil {61 return err62 }63 if _, err := io.Copy(entry, r); err != nil {64 return fmt.Errorf("write %s: %w", name, err)65 }66 return nil67}6869// AddDeflated writes data into the bundle compressed, which suits the JSON70// payloads: text shrinks severalfold and is read whole when a world opens.71func (w *Writer) AddDeflated(name string, data []byte) error {72 entry, err := w.create(name, zip.Deflate)73 if err != nil {74 return err75 }76 if _, err := entry.Write(data); err != nil {77 return fmt.Errorf("write %s: %w", name, err)78 }79 return nil80}8182// Close finishes the archive. The bundle is not readable until it returns.83func (w *Writer) Close() error {84 if err := w.zip.Close(); err != nil {85 return fmt.Errorf("close archive: %w", err)86 }87 return nil88}8990func (w *Writer) create(name string, method uint16) (io.Writer, error) {91 if err := ValidEntryName(name); err != nil {92 return nil, err93 }94 if w.names[name] {95 return nil, fmt.Errorf("entry %s is written twice", name)96 }97 w.names[name] = true98 entry, err := w.zip.CreateHeader(&zip.FileHeader{Name: name, Method: method})99 if err != nil {100 return nil, fmt.Errorf("create %s: %w", name, err)101 }102 return entry, nil103}104105// ValidEntryName admits the names the format uses -- slash-separated relative106// paths of plain segments -- and nothing a hostile or careless producer could107// use to climb out of the archive's own namespace. A reader that unpacks a108// bundle to disk is entitled to assume this held.109func ValidEntryName(name string) error {110 if name == "" || strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") {111 return fmt.Errorf("entry name %q is not a relative file path", name)112 }113 for segment := range strings.SplitSeq(name, "/") {114 if segment == "" || segment == "." || segment == ".." {115 return fmt.Errorf("entry name %q climbs or stutters", name)116 }117 }118 return nil119}