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 the reader would refuse to open.13type Writer struct {14 zw *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.20func NewWriter(w io.Writer, manifest Manifest) (*Writer, error) {21 if err := manifest.Validate(); err != nil {22 return nil, fmt.Errorf("manifest: %w", err)23 }24 data, err := json.Marshal(manifest)25 if err != nil {26 return nil, fmt.Errorf("marshal manifest: %w", err)27 }28 out := &Writer{zw: zip.NewWriter(w), names: map[string]bool{ManifestName: true}}29 entry, err := out.zw.CreateHeader(&zip.FileHeader{Name: ManifestName, Method: zip.Deflate})30 if err != nil {31 return nil, err32 }33 if _, err := entry.Write(data); err != nil {34 return nil, err35 }36 return out, nil37}3839// AddStored copies r into the bundle uncompressed. Tiles and packed locations40// go in this way: they are already dense, and a stored entry is served as a41// straight byte range where a deflated one is inflated on every read.42func (w *Writer) AddStored(name string, r io.Reader) error {43 entry, err := w.create(name, zip.Store)44 if err != nil {45 return err46 }47 _, err = io.Copy(entry, r)48 return err49}5051// AddDeflated writes data into the bundle compressed, which suits the JSON52// payloads: text shrinks severalfold and is read whole when a map opens.53func (w *Writer) AddDeflated(name string, data []byte) error {54 entry, err := w.create(name, zip.Deflate)55 if err != nil {56 return err57 }58 _, err = entry.Write(data)59 return err60}6162// Close finishes the archive. The bundle is not readable until it returns.63func (w *Writer) Close() error {64 return w.zw.Close()65}6667func (w *Writer) create(name string, method uint16) (io.Writer, error) {68 if err := validEntryName(name); err != nil {69 return nil, err70 }71 if w.names[name] {72 return nil, fmt.Errorf("entry %s is written twice", name)73 }74 w.names[name] = true75 return w.zw.CreateHeader(&zip.FileHeader{Name: name, Method: method})76}7778// validEntryName admits the names the format uses -- slash-separated relative79// paths of plain segments -- and nothing a hostile or careless producer could80// use to climb out of the archive's own namespace.81func validEntryName(name string) error {82 if name == "" || strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") {83 return fmt.Errorf("entry name %q is not a relative file path", name)84 }85 for segment := range strings.SplitSeq(name, "/") {86 if segment == "" || segment == "." || segment == ".." {87 return fmt.Errorf("entry name %q climbs or stutters", name)88 }89 }90 return nil91}