clone

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

feat/atlas-next-horizon
atlas / format / bundle / validate.go
validate.go 12.0K
1package bundle23import (4 "encoding/json"5 "fmt"6 "sort"7 "strconv"8 "strings"910 "github.com/FelineStateMachine/atlas/format/semconv"11)1213// runtimeSchemes are what the offline scan looks for. Atlas runs with no14// network, so a URL in a payload is dead weight at best and a silent15// dependency on somebody else's server at worst.16var runtimeSchemes = []string{"http://", "https://"}1718// urlExcerpt bounds how much of an offending payload an error quotes: enough19// to find the line, not enough to fill a terminal.20const urlExcerpt = 1202122// worldPeek is the sliver of a world payload validation reads: which pyramids23// its lenses draw from, which icons its collections name, what kind of24// features each collection declares and inlines, and what the payload says of25// itself in the shared conventions.26//27// It is deliberately partial. Validation holds the format's promises; it does28// not model the payload, which stays a producer's and a renderer's business.29type worldPeek struct {30 Lenses []struct {31 Tiles string `json:"tiles"`32 MinZoom int `json:"minZoom"`33 MaxZoom int `json:"maxZoom"`34 Formats []string `json:"formats"`35 } `json:"lenses"`36 Collections []struct {37 ID int64 `json:"id"`38 Title string `json:"title"`39 Kind string `json:"kind"`40 IconAsset string `json:"iconAsset"`41 Attrs map[string]string `json:"attrs"`42 Features []struct {43 Geometry []struct {44 Type string `json:"type"`45 } `json:"geometry"`46 Attrs map[string]string `json:"attrs"`47 } `json:"features"`48 } `json:"collections"`49 Attrs map[string]string `json:"attrs"`50}5152// textPeek is the sliver of a feature's text entry validation reads: its53// attributes, which are the only conventions that ride the text file.54type textPeek struct {55 Attrs map[string]string `json:"a"`56}5758// Validate checks the manifest's promises against the archive:59//60// - every world has its three payload entries;61// - no payload carries a runtime URL (the offline-purity invariant);62// - the packed locations and inline shape features agree with the63// advertised per-kind counts;64// - every collection's features are the kind it declares, and every packed65// location is owned by a point collection that exists;66// - every lens names as many tile formats as it has levels, and every level67// holds tiles;68// - every named icon exists;69// - and, for a bundle that declares conventions, every attribute is70// registered, well-formed, and attached where the registry says it lives.71//72// Producers and importers run this. Opening a bundle does not: it costs a73// pass over every payload.74func (r *Reader) Validate() error {75 levels := r.entryCountsByDirectory()76 for _, entry := range r.Manifest.Worlds {77 if err := r.validateWorld(entry, levels); err != nil {78 return err79 }80 }81 return nil82}8384// entryCountsByDirectory counts the archive's entries per immediate85// directory, so a lens can ask whether a level holds tiles without a scan per86// level.87func (r *Reader) entryCountsByDirectory() map[string]int {88 counts := make(map[string]int)89 for name := range r.entries {90 if directory := name[:strings.LastIndexByte(name, '/')+1]; directory != "" {91 counts[directory]++92 }93 }94 return counts95}9697func (r *Reader) validateWorld(entry WorldEntry, levels map[string]int) error {98 detail, err := r.ReadEntry(WorldEntryName(entry.Slug, WorldSuffix))99 if err != nil {100 return fmt.Errorf("world %s: %w", entry.Slug, err)101 }102 text, err := r.ReadEntry(WorldEntryName(entry.Slug, TextSuffix))103 if err != nil {104 return fmt.Errorf("world %s: %w", entry.Slug, err)105 }106 for _, payload := range [][]byte{detail, text} {107 if err := scanOffline(entry.Slug, payload); err != nil {108 return err109 }110 }111112 packed, err := r.ReadEntry(WorldEntryName(entry.Slug, PackedSuffix))113 if err != nil {114 return fmt.Errorf("world %s: %w", entry.Slug, err)115 }116 locations, err := UnpackLocations(packed)117 if err != nil {118 return fmt.Errorf("world %s: %w", entry.Slug, err)119 }120 if len(locations) != entry.Points {121 return fmt.Errorf("world %s packs %d locations, and the manifest says %d points",122 entry.Slug, len(locations), entry.Points)123 }124125 var peek worldPeek126 if err := json.Unmarshal(detail, &peek); err != nil {127 return fmt.Errorf("world %s: decode payload: %w", entry.Slug, err)128 }129 if err := r.validateLenses(entry, peek, levels); err != nil {130 return err131 }132 if err := r.validateCollections(entry, peek, locations); err != nil {133 return err134 }135 // The strictness belongs to the claim: a bundle that declares no136 // conventions is held to none.137 if r.Manifest.Conventions >= 1 {138 if err := validateConventions(entry.Slug, peek, text); err != nil {139 return err140 }141 }142 return nil143}144145// scanOffline is the offline-purity invariant, enforced: a payload may hold146// no URL a reader could be tempted to fetch.147func scanOffline(slug string, payload []byte) error {148 body := string(payload)149 for _, scheme := range runtimeSchemes {150 if at := strings.Index(body, scheme); at >= 0 {151 return fmt.Errorf("world %s carries a runtime URL: %q",152 slug, body[at:min(at+urlExcerpt, len(body))])153 }154 }155 return nil156}157158// validateLenses holds a world's pictures to what they promise: a world has159// at least one lens, a lens names one tile format per level, and every level160// it claims holds tiles.161func (r *Reader) validateLenses(entry WorldEntry, peek worldPeek, levels map[string]int) error {162 if len(peek.Lenses) == 0 {163 return fmt.Errorf("world %s has no lenses", entry.Slug)164 }165 for _, lens := range peek.Lenses {166 if len(lens.Formats) != lens.MaxZoom-lens.MinZoom+1 {167 return fmt.Errorf("world %s lens %s names %d tile formats for %d levels",168 entry.Slug, lens.Tiles, len(lens.Formats), lens.MaxZoom-lens.MinZoom+1)169 }170 for zoom := lens.MinZoom; zoom <= lens.MaxZoom; zoom++ {171 prefix := TilesPrefix + lens.Tiles + "/" + strconv.Itoa(zoom) + "/"172 var held int173 for directory, count := range levels {174 if strings.HasPrefix(directory, prefix) {175 held += count176 }177 }178 if held == 0 {179 return fmt.Errorf("world %s lens %s has an empty tile level %d",180 entry.Slug, lens.Tiles, zoom)181 }182 }183 }184 return nil185}186187// validateCollections holds a world's collections array to the shape the188// format declares, whatever vocabulary the bundle speaks: every collection a189// known kind with a distinct id, point features packed rather than inlined,190// inline geometry agreeing with its collection's kind, every path collection191// carrying the stroke width a path is drawn at, label policy only where192// labels draw on ground, the per-kind counts matching the manifest, and every193// packed location owned by a point collection that exists.194func (r *Reader) validateCollections(entry WorldEntry, peek worldPeek, locations []Location) error {195 ids := make(map[int64]string, len(peek.Collections))196 var paths, areas int197 for _, collection := range peek.Collections {198 if holder, taken := ids[collection.ID]; taken {199 return fmt.Errorf("world %s collections %q and %q share id %d",200 entry.Slug, holder, collection.Title, collection.ID)201 }202 ids[collection.ID] = collection.Title203 if collection.IconAsset != "" && !r.Has(IconsPrefix+collection.IconAsset) {204 return fmt.Errorf("world %s names a missing icon %s", entry.Slug, collection.IconAsset)205 }206 switch collection.Kind {207 case semconv.GeometryPoint:208 if len(collection.Features) > 0 {209 return fmt.Errorf("world %s point collection %q carries %d inline features; points ride the packed payload",210 entry.Slug, collection.Title, len(collection.Features))211 }212 case semconv.GeometryPath, semconv.GeometryArea:213 for _, feature := range collection.Features {214 for _, geometry := range feature.Geometry {215 if !GeometryFitsKind(collection.Kind, geometry.Type) {216 return fmt.Errorf("world %s %s collection %q inlines a %s",217 entry.Slug, collection.Kind, collection.Title, geometry.Type)218 }219 }220 }221 if collection.Kind == semconv.GeometryPath {222 paths += len(collection.Features)223 if collection.Attrs[semconv.KeyStrokeWidthPx] == "" {224 return fmt.Errorf("world %s path collection %q declares no %s",225 entry.Slug, collection.Title, semconv.KeyStrokeWidthPx)226 }227 } else {228 areas += len(collection.Features)229 }230 default:231 return fmt.Errorf("world %s collection %q declares kind %q, which is none of %s, %s, %s",232 entry.Slug, collection.Title, collection.Kind,233 semconv.GeometryPoint, semconv.GeometryPath, semconv.GeometryArea)234 }235 if _, spoken := collection.Attrs[semconv.KeyLabelPolicy]; spoken && collection.Kind != semconv.GeometryArea {236 return fmt.Errorf("world %s %s collection %q declares a label policy; only areas curate their labels",237 entry.Slug, collection.Kind, collection.Title)238 }239 }240 if paths != entry.Paths || areas != entry.Areas {241 return fmt.Errorf("world %s inlines %d paths and %d areas, and the manifest says %d and %d",242 entry.Slug, paths, areas, entry.Paths, entry.Areas)243 }244 for _, location := range locations {245 if int(location.Owner) >= len(peek.Collections) ||246 peek.Collections[location.Owner].Kind != semconv.GeometryPoint {247 return fmt.Errorf("world %s location %d names collection index %d, which is no point collection",248 entry.Slug, location.ID, location.Owner)249 }250 }251 return nil252}253254// GeometryFitsKind says which GeoJSON geometry types a collection kind may255// inline: ground for areas, lines for paths. Points inline nothing at all --256// they ride the packed payload -- so no geometry type fits them.257func GeometryFitsKind(kind, geometryType string) bool {258 switch kind {259 case semconv.GeometryArea:260 return geometryType == "MultiPolygon" || geometryType == "Polygon"261 case semconv.GeometryPath:262 return geometryType == "MultiLineString" || geometryType == "LineString"263 }264 return false265}266267// validateConventions holds a declaring bundle to the vocabulary it claims:268// every attribute registered, well-formed, and attached where the registry269// says it lives -- collections speak for their kind, stroke, and labels;270// features speak only for themselves -- a declared standard icon actually271// resolved, a declared kind agreeing with the wire's own field, and a declared272// sphere carrying a mapping that parses.273func validateConventions(slug string, peek worldPeek, text []byte) error {274 if err := semconv.Validate(semconv.EntityWorld, peek.Attrs); err != nil {275 return fmt.Errorf("world %s: %w", slug, err)276 }277 if peek.Attrs[semconv.KeyGeometrySurface] == semconv.SurfaceSphere {278 if peek.Attrs[semconv.KeyGeometryProjection] == "" {279 return fmt.Errorf("world %s declares a sphere with no projection", slug)280 }281 if _, err := semconv.ParseEquirect(282 peek.Attrs[semconv.KeyGeometryEquirectPx],283 peek.Attrs[semconv.KeyGeometryEquirectDeg]); err != nil {284 return fmt.Errorf("world %s: %w", slug, err)285 }286 }287 for _, collection := range peek.Collections {288 if err := semconv.Validate(semconv.EntityCollection, collection.Attrs); err != nil {289 return fmt.Errorf("world %s collection %q: %w", slug, collection.Title, err)290 }291 if declared, spoken := collection.Attrs[semconv.KeyGeometryKind]; spoken && declared != collection.Kind {292 return fmt.Errorf("world %s collection %q says kind %s while its attributes say %s",293 slug, collection.Title, collection.Kind, declared)294 }295 if collection.Attrs[semconv.KeyIconStd] != "" && collection.IconAsset == "" {296 return fmt.Errorf("world %s declares a standard icon that was never resolved", slug)297 }298 for _, feature := range collection.Features {299 if err := semconv.Validate(semconv.EntityFeature, feature.Attrs); err != nil {300 return fmt.Errorf("world %s: %w", slug, err)301 }302 }303 }304 var entries map[string]textPeek305 if err := json.Unmarshal(text, &entries); err != nil {306 return fmt.Errorf("world %s: decode text: %w", slug, err)307 }308 // Sorted, so a payload with several faults always reports the same one.309 ids := make([]string, 0, len(entries))310 for id := range entries {311 ids = append(ids, id)312 }313 sort.Strings(ids)314 for _, id := range ids {315 if err := semconv.Validate(semconv.EntityFeature, entries[id].Attrs); err != nil {316 return fmt.Errorf("world %s feature %s: %w", slug, id, err)317 }318 }319 return nil320}