clone

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

archive/golden-reference
atlas / internal / bundle / read.go
read.go 13.1K
1package bundle23import (4 "archive/zip"5 "encoding/json"6 "fmt"7 "io"8 "os"9 "strconv"10 "strings"11 "time"1213 "github.com/FelineStateMachine/atlas/internal/semconv"14)1516// maxManifestSize bounds what Open will read as a manifest. A manifest lists17// a volume's worlds and nothing per pin, so a megabyte is already generous; a18// larger one is some other file wearing the name.19const maxManifestSize = 1 << 202021// Bundle is an opened .atlas file. The archive stays open for the bundle's22// lifetime, so serving an entry is a seek and a read against the one held23// descriptor rather than a fresh open of a multi-hundred-megabyte file.24type Bundle struct {25 Path string26 Manifest Manifest2728 archive *zip.ReadCloser29 entries map[string]*zip.File3031 // size and modTime are the file as it was opened, letting a rescan tell32 // an untouched file from one rewritten in place under the same name.33 size int6434 modTime time.Time35}3637// Open reads the manifest of the bundle at path and refuses anything that is38// not a bundle this reader understands. It does not check the manifest's39// promises against the archive's contents; that is Validate, which costs a40// pass over every payload and is for producers and importers, not for every41// launch.42func Open(path string) (*Bundle, error) {43 archive, err := zip.OpenReader(path)44 if err != nil {45 return nil, fmt.Errorf("open %s: %w", path, err)46 }47 bundle := &Bundle{48 Path: path,49 archive: archive,50 entries: make(map[string]*zip.File, len(archive.File)),51 }52 if info, err := os.Stat(path); err == nil {53 bundle.size, bundle.modTime = info.Size(), info.ModTime()54 }55 for _, file := range archive.File {56 bundle.entries[file.Name] = file57 }5859 manifest, ok := bundle.entries[ManifestName]60 if !ok {61 archive.Close()62 return nil, fmt.Errorf("%s carries no %s, so it is not a bundle", path, ManifestName)63 }64 if manifest.UncompressedSize64 > maxManifestSize {65 archive.Close()66 return nil, fmt.Errorf("%s: manifest is implausibly large", path)67 }68 reader, err := manifest.Open()69 if err != nil {70 archive.Close()71 return nil, fmt.Errorf("%s: open manifest: %w", path, err)72 }73 data, err := io.ReadAll(io.LimitReader(reader, maxManifestSize))74 reader.Close()75 if err != nil {76 archive.Close()77 return nil, fmt.Errorf("%s: read manifest: %w", path, err)78 }79 if err := json.Unmarshal(data, &bundle.Manifest); err != nil {80 archive.Close()81 return nil, fmt.Errorf("%s: decode manifest: %w", path, err)82 }83 if err := bundle.Manifest.Validate(); err != nil {84 archive.Close()85 return nil, fmt.Errorf("%s: %w", path, err)86 }87 return bundle, nil88}8990// Has reports whether the bundle holds an entry by this name.91func (b *Bundle) Has(name string) bool {92 _, ok := b.entries[name]93 return ok94}9596// OpenEntry opens one entry for reading and reports its uncompressed size, so97// a server can announce a length before streaming.98func (b *Bundle) OpenEntry(name string) (io.ReadCloser, int64, error) {99 entry, ok := b.entries[name]100 if !ok {101 return nil, 0, fmt.Errorf("%s holds no %s", b.Path, name)102 }103 reader, err := entry.Open()104 if err != nil {105 return nil, 0, err106 }107 return reader, int64(entry.UncompressedSize64), nil108}109110// ReadEntry reads one entry whole.111func (b *Bundle) ReadEntry(name string) ([]byte, error) {112 reader, _, err := b.OpenEntry(name)113 if err != nil {114 return nil, err115 }116 defer reader.Close()117 return io.ReadAll(reader)118}119120// Close releases the archive. Entries opened before must not be read after.121func (b *Bundle) Close() error {122 return b.archive.Close()123}124125// payloadPeek is the sliver of a world payload that validation reads: which126// pyramids its layers draw from, which icons its collections name, what kind127// of features each collection declares and inlines, and what the payload128// says of itself in the shared conventions.129type payloadPeek struct {130 Lenses []struct {131 Tiles string `json:"tiles"`132 MinZoom int `json:"minZoom"`133 MaxZoom int `json:"maxZoom"`134 Formats []string `json:"formats"`135 } `json:"lenses"`136 Collections []struct {137 ID int64 `json:"id"`138 Title string `json:"title"`139 Kind string `json:"kind"`140 IconAsset string `json:"iconAsset"`141 Attrs map[string]string `json:"attrs"`142 Features []struct {143 Geometry []struct {144 Type string `json:"type"`145 } `json:"geometry"`146 Attrs map[string]string `json:"attrs"`147 } `json:"features"`148 } `json:"collections"`149 Attrs map[string]string `json:"attrs"`150}151152// textPeek is the sliver of a pin's text entry that validation reads: its153// attributes, which are the only conventions that ride the text file.154type textPeek struct {155 Attrs map[string]string `json:"a"`156}157158// Validate checks the manifest's promises against the archive: every world159// has its three payloads, the packed locations and inline shape features160// agree with the advertised per-kind counts, every collection's features are161// the kind it declares, every lens's tile levels hold tiles, every named162// icon exists, and nothing carries a live URL -- Atlas runs with no network,163// so a URL in a bundle is dead weight at best. Producers and importers run164// this; opening a bundle at launch does not.165func (b *Bundle) Validate() error {166 levels := make(map[string]int)167 for name := range b.entries {168 if directory := name[:strings.LastIndexByte(name, '/')+1]; directory != "" {169 levels[directory]++170 }171 }172173 for _, entry := range b.Manifest.Worlds {174 detail, err := b.ReadEntry("worlds/" + entry.Slug + ".json")175 if err != nil {176 return fmt.Errorf("world %s: %w", entry.Slug, err)177 }178 text, err := b.ReadEntry("worlds/" + entry.Slug + ".text")179 if err != nil {180 return fmt.Errorf("world %s: %w", entry.Slug, err)181 }182 for _, payload := range [][]byte{detail, text} {183 for _, scheme := range []string{"http://", "https://"} {184 if at := strings.Index(string(payload), scheme); at >= 0 {185 return fmt.Errorf("world %s carries a runtime URL: %q",186 entry.Slug, payload[at:min(at+120, len(payload))])187 }188 }189 }190191 packed, err := b.ReadEntry("worlds/" + entry.Slug + ".bin")192 if err != nil {193 return fmt.Errorf("world %s: %w", entry.Slug, err)194 }195 locations, err := UnpackLocations(packed)196 if err != nil {197 return fmt.Errorf("world %s: %w", entry.Slug, err)198 }199 if len(locations) != entry.Points {200 return fmt.Errorf("world %s packs %d locations, and the manifest says %d points",201 entry.Slug, len(locations), entry.Points)202 }203204 var peek payloadPeek205 if err := json.Unmarshal(detail, &peek); err != nil {206 return fmt.Errorf("world %s: decode payload: %w", entry.Slug, err)207 }208 if len(peek.Lenses) == 0 {209 return fmt.Errorf("world %s has no lenses", entry.Slug)210 }211 for _, lens := range peek.Lenses {212 if len(lens.Formats) != lens.MaxZoom-lens.MinZoom+1 {213 return fmt.Errorf("world %s lens %s names %d tile formats for %d levels",214 entry.Slug, lens.Tiles, len(lens.Formats), lens.MaxZoom-lens.MinZoom+1)215 }216 for zoom := lens.MinZoom; zoom <= lens.MaxZoom; zoom++ {217 prefix := "tiles/" + lens.Tiles + "/" + strconv.Itoa(zoom) + "/"218 var held int219 for directory, count := range levels {220 if strings.HasPrefix(directory, prefix) {221 held += count222 }223 }224 if held == 0 {225 return fmt.Errorf("world %s lens %s has an empty tile level %d",226 entry.Slug, lens.Tiles, zoom)227 }228 }229 }230 if err := b.validateCollections(entry, peek, locations); err != nil {231 return err232 }233 if b.Manifest.Conventions >= 1 {234 if err := validateConventions(entry.Slug, peek, text); err != nil {235 return err236 }237 }238 }239 return nil240}241242// validateCollections holds a world's collections array to the shape the243// format declares, whatever vocabulary the bundle speaks: every collection a244// known kind with a distinct id, point features packed rather than inlined,245// inline geometry agreeing with its collection's kind, every path collection246// carrying the stroke width a path is drawn at, label policy only where247// labels draw on ground, the per-kind counts matching the manifest, and every248// packed location owned by a point collection that exists.249func (b *Bundle) validateCollections(entry WorldEntry, peek payloadPeek, locations []Location) error {250 ids := make(map[int64]string, len(peek.Collections))251 var paths, areas int252 for _, collection := range peek.Collections {253 if holder, taken := ids[collection.ID]; taken {254 return fmt.Errorf("world %s collections %q and %q share id %d",255 entry.Slug, holder, collection.Title, collection.ID)256 }257 ids[collection.ID] = collection.Title258 if collection.IconAsset != "" && !b.Has("icons/"+collection.IconAsset) {259 return fmt.Errorf("world %s names a missing icon %s", entry.Slug, collection.IconAsset)260 }261 switch collection.Kind {262 case semconv.GeometryPoint:263 if len(collection.Features) > 0 {264 return fmt.Errorf("world %s point collection %q carries %d inline features; points ride the packed payload",265 entry.Slug, collection.Title, len(collection.Features))266 }267 case semconv.GeometryPath, semconv.GeometryArea:268 for _, feature := range collection.Features {269 for _, geometry := range feature.Geometry {270 if !geometryFitsKind(collection.Kind, geometry.Type) {271 return fmt.Errorf("world %s %s collection %q inlines a %s",272 entry.Slug, collection.Kind, collection.Title, geometry.Type)273 }274 }275 }276 if collection.Kind == semconv.GeometryPath {277 paths += len(collection.Features)278 if collection.Attrs[semconv.KeyStrokeWidthPx] == "" {279 return fmt.Errorf("world %s path collection %q declares no %s",280 entry.Slug, collection.Title, semconv.KeyStrokeWidthPx)281 }282 } else {283 areas += len(collection.Features)284 }285 default:286 return fmt.Errorf("world %s collection %q declares kind %q, which is none of point, path, area",287 entry.Slug, collection.Title, collection.Kind)288 }289 if _, spoken := collection.Attrs[semconv.KeyLabelPolicy]; spoken && collection.Kind != semconv.GeometryArea {290 return fmt.Errorf("world %s %s collection %q declares a label policy; only areas curate their labels",291 entry.Slug, collection.Kind, collection.Title)292 }293 }294 if paths != entry.Paths || areas != entry.Areas {295 return fmt.Errorf("world %s inlines %d paths and %d areas, and the manifest says %d and %d",296 entry.Slug, paths, areas, entry.Paths, entry.Areas)297 }298 for _, location := range locations {299 if int(location.Owner) >= len(peek.Collections) ||300 peek.Collections[location.Owner].Kind != semconv.GeometryPoint {301 return fmt.Errorf("world %s location %d names collection index %d, which is no point collection",302 entry.Slug, location.ID, location.Owner)303 }304 }305 return nil306}307308// geometryFitsKind says which GeoJSON geometry types a collection kind may309// inline: ground for areas, lines for paths.310func geometryFitsKind(kind, geometryType string) bool {311 switch kind {312 case semconv.GeometryArea:313 return geometryType == "MultiPolygon" || geometryType == "Polygon"314 case semconv.GeometryPath:315 return geometryType == "MultiLineString" || geometryType == "LineString"316 }317 return false318}319320// validateConventions holds a declaring bundle to the vocabulary it claims:321// every attribute registered, well-formed, and attached where the registry322// says it lives -- collections speak for their kind, stroke, and labels;323// features speak only for themselves -- a declared standard icon actually324// resolved, a declared kind agreeing with the wire's own field, and a325// declared sphere carrying a mapping that parses. A bundle that declares no326// conventions is not held to any -- the strictness belongs to the claim.327func validateConventions(slug string, peek payloadPeek, text []byte) error {328 if err := semconv.Validate(semconv.EntityWorld, peek.Attrs); err != nil {329 return fmt.Errorf("world %s: %w", slug, err)330 }331 if peek.Attrs[semconv.KeyGeometrySurface] == semconv.SurfaceSphere {332 if peek.Attrs[semconv.KeyGeometryProjection] == "" {333 return fmt.Errorf("world %s declares a sphere with no projection", slug)334 }335 if _, err := semconv.ParseEquirect(336 peek.Attrs[semconv.KeyGeometryEquirectPx],337 peek.Attrs[semconv.KeyGeometryEquirectDeg]); err != nil {338 return fmt.Errorf("world %s: %w", slug, err)339 }340 }341 for _, collection := range peek.Collections {342 if err := semconv.Validate(semconv.EntityCollection, collection.Attrs); err != nil {343 return fmt.Errorf("world %s collection %q: %w", slug, collection.Title, err)344 }345 if declared, spoken := collection.Attrs[semconv.KeyGeometryKind]; spoken && declared != collection.Kind {346 return fmt.Errorf("world %s collection %q says kind %s while its attributes say %s",347 slug, collection.Title, collection.Kind, declared)348 }349 if collection.Attrs[semconv.KeyIconStd] != "" && collection.IconAsset == "" {350 return fmt.Errorf("world %s declares a standard icon that was never resolved", slug)351 }352 for _, feature := range collection.Features {353 if err := semconv.Validate(semconv.EntityFeature, feature.Attrs); err != nil {354 return fmt.Errorf("world %s: %w", slug, err)355 }356 }357 }358 var entries map[string]textPeek359 if err := json.Unmarshal(text, &entries); err != nil {360 return fmt.Errorf("world %s: decode text: %w", slug, err)361 }362 for id, entry := range entries {363 if err := semconv.Validate(semconv.EntityFeature, entry.Attrs); err != nil {364 return fmt.Errorf("world %s pin %s: %w", slug, id, err)365 }366 }367 return nil368}