package export // format.go — v0.34.0 CS0 // // Defines the .switchboard archive format: a zip file containing a // manifest.json and per-entity JSON files under data/. File blobs // live under files/{id}/{filename}. import ( "archive/zip" "encoding/json" "fmt" "io" "time" ) // ── Constants ──────────────────────────────── const ( FormatVersion = 1 MaxExportArchiveSize = 500 * 1024 * 1024 // 500 MB total MaxExportFileSize = 100 * 1024 * 1024 // 100 MB per file blob MaxExportFiles = 10000 ExportContentType = "application/zip" ExportExtension = ".switchboard" ) // ── Manifest ───────────────────────────────── // Manifest is the top-level metadata written to manifest.json. type Manifest struct { Version string `json:"version"` // server version, e.g. "0.34.0" FormatVersion int `json:"format_version"` // always 1 for now ExportType string `json:"export_type"` // "user", "team" CreatedAt time.Time `json:"created_at"` ExportedBy string `json:"exported_by"` // user ID EntityCounts map[string]int `json:"entity_counts"` Scope *ManifestScope `json:"scope,omitempty"` } // ManifestScope narrows the export to a user or team. type ManifestScope struct { UserID string `json:"user_id,omitempty"` TeamID string `json:"team_id,omitempty"` } // ── ArchiveWriter ──────────────────────────── // ArchiveWriter wraps zip.Writer with size tracking and convenience // methods for writing manifest and entity JSON files. type ArchiveWriter struct { zw *zip.Writer written int64 maxBytes int64 } // NewArchiveWriter creates an ArchiveWriter that streams to w. func NewArchiveWriter(w io.Writer) *ArchiveWriter { return &ArchiveWriter{ zw: zip.NewWriter(w), maxBytes: MaxExportArchiveSize, } } // WriteManifest serializes m as indented JSON into manifest.json. func (aw *ArchiveWriter) WriteManifest(m *Manifest) error { data, err := json.MarshalIndent(m, "", " ") if err != nil { return fmt.Errorf("export: marshal manifest: %w", err) } return aw.writeEntry("manifest.json", data) } // WriteEntityJSON serializes data as indented JSON into data/{name}.json. func (aw *ArchiveWriter) WriteEntityJSON(name string, data interface{}) (int, error) { buf, err := json.MarshalIndent(data, "", " ") if err != nil { return 0, fmt.Errorf("export: marshal %s: %w", name, err) } if err := aw.writeEntry("data/"+name+".json", buf); err != nil { return 0, err } // Return entity count for manifest switch v := data.(type) { case []interface{}: return len(v), nil default: // Use json.RawMessage length heuristic — caller tracks count return 0, nil } } // WriteFile streams a file blob into files/{zipPath}. Returns bytes // written or an error if the file exceeds MaxExportFileSize. func (aw *ArchiveWriter) WriteFile(zipPath string, r io.Reader) (int64, error) { if aw.written+1 > aw.maxBytes { return 0, fmt.Errorf("export: archive size limit exceeded") } f, err := aw.zw.Create("files/" + zipPath) if err != nil { return 0, fmt.Errorf("export: create zip entry %s: %w", zipPath, err) } lr := io.LimitReader(r, MaxExportFileSize+1) n, err := io.Copy(f, lr) if err != nil { return n, fmt.Errorf("export: write file %s: %w", zipPath, err) } if n > MaxExportFileSize { return n, fmt.Errorf("export: file %s exceeds %d bytes", zipPath, MaxExportFileSize) } aw.written += n return n, nil } // Close finalizes the zip archive. func (aw *ArchiveWriter) Close() error { return aw.zw.Close() } // BytesWritten returns the approximate bytes written so far. func (aw *ArchiveWriter) BytesWritten() int64 { return aw.written } // ── ArchiveReader ──────────────────────────── // ArchiveReader wraps zip.ReadCloser with validation helpers. type ArchiveReader struct { zr *zip.ReadCloser } // OpenArchive opens and validates a .switchboard zip archive. func OpenArchive(path string) (*ArchiveReader, error) { zr, err := zip.OpenReader(path) if err != nil { return nil, fmt.Errorf("export: open archive: %w", err) } return &ArchiveReader{zr: zr}, nil } // ReadManifest reads and validates manifest.json from the archive. func (ar *ArchiveReader) ReadManifest() (*Manifest, error) { for _, f := range ar.zr.File { if f.Name == "manifest.json" { rc, err := f.Open() if err != nil { return nil, fmt.Errorf("export: open manifest: %w", err) } defer rc.Close() var m Manifest if err := json.NewDecoder(rc).Decode(&m); err != nil { return nil, fmt.Errorf("export: decode manifest: %w", err) } if m.FormatVersion > FormatVersion { return nil, fmt.Errorf("export: unsupported format version %d (max %d)", m.FormatVersion, FormatVersion) } return &m, nil } } return nil, fmt.Errorf("export: manifest.json not found in archive") } // ReadEntityJSON reads data/{name}.json into target. func (ar *ArchiveReader) ReadEntityJSON(name string, target interface{}) error { path := "data/" + name + ".json" for _, f := range ar.zr.File { if f.Name == path { rc, err := f.Open() if err != nil { return fmt.Errorf("export: open %s: %w", path, err) } defer rc.Close() if err := json.NewDecoder(rc).Decode(target); err != nil { return fmt.Errorf("export: decode %s: %w", path, err) } return nil } } // Entity file not present — not an error (sparse archives are valid) return nil } // FileEntries returns zip entries under files/. func (ar *ArchiveReader) FileEntries() []*zip.File { var out []*zip.File for _, f := range ar.zr.File { if len(f.Name) > 6 && f.Name[:6] == "files/" && !f.FileInfo().IsDir() { out = append(out, f) } } return out } // Close closes the underlying zip reader. func (ar *ArchiveReader) Close() error { return ar.zr.Close() } // writeEntry writes raw bytes as a zip entry, tracking total size. func (aw *ArchiveWriter) writeEntry(name string, data []byte) error { if aw.written+int64(len(data)) > aw.maxBytes { return fmt.Errorf("export: archive size limit exceeded writing %s", name) } f, err := aw.zw.Create(name) if err != nil { return fmt.Errorf("export: create entry %s: %w", name, err) } n, err := f.Write(data) aw.written += int64(n) return err }