// Package outboxarchiverestore verifies one immutable wallet_outbox archive batch // and restores it into versioned isolation tables. It deliberately contains no // purge API: a successful rehearsal is evidence for a later retention gate, not // authorization to mutate the owner outbox. package outboxarchiverestore import ( "bufio" "bytes" "compress/gzip" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "hash/crc64" "io" "math/big" "os" "path" "strconv" "strings" "time" ) const ( archiveSchema = "hyapp.wallet_outbox.archive" archiveSchemaVersion = 1 manifestState = "DATA_VERIFIED" successState = "VERIFIED" filesVerifiedState = "FILES_VERIFIED" restoreVerifiedState = "RESTORE_VERIFIED" maxManifestBytes = 1 << 20 maxSuccessBytes = 64 << 10 maxNDJSONLine = 64 << 20 ) // Limits bounds local CPU, memory and disk exposure before the operator-selected // restore database is opened. Values are intentionally independent of worker // configuration so an accidentally supplied object cannot bypass the CLI guard. type Limits struct { MaxRows int MaxCompressedBytes int64 MaxUncompressedBytes int64 } func DefaultLimits() Limits { return Limits{MaxRows: 100000, MaxCompressedBytes: 512 << 20, MaxUncompressedBytes: 1 << 30} } type Cursor struct { UpdatedAtMS int64 `json:"updated_at_ms"` EventID string `json:"event_id"` } type Manifest struct { Schema string `json:"schema"` SchemaVersion int `json:"schema_version"` State string `json:"state"` AppCode string `json:"app_code"` BatchID string `json:"batch_id"` RowCount int `json:"row_count"` FirstCursor Cursor `json:"first_cursor"` LastCursor Cursor `json:"last_cursor"` MinCreatedAtMS int64 `json:"min_created_at_ms"` MaxCreatedAtMS int64 `json:"max_created_at_ms"` MinUpdatedAtMS int64 `json:"min_updated_at_ms"` MaxUpdatedAtMS int64 `json:"max_updated_at_ms"` AvailableDeltaTotal string `json:"available_delta_total"` FrozenDeltaTotal string `json:"frozen_delta_total"` NDJSONSHA256 string `json:"ndjson_sha256"` GzipSHA256 string `json:"gzip_sha256"` DataCRC64 string `json:"data_crc64"` UncompressedBytes int64 `json:"uncompressed_bytes"` CompressedBytes int64 `json:"compressed_bytes"` DataObjectKey string `json:"data_object_key"` } type Success struct { Schema string `json:"schema"` SchemaVersion int `json:"schema_version"` State string `json:"state"` BatchID string `json:"batch_id"` ManifestSHA256 string `json:"manifest_sha256"` ManifestCRC64 string `json:"manifest_crc64"` } // Record mirrors every wallet_outbox column. Restore tables add rehearsal and // source-batch keys around this payload rather than changing owner-table fields, // so later historical tools can compare one archived row without translation. type Record struct { AppCode string `json:"app_code"` EventID string `json:"event_id"` EventType string `json:"event_type"` TransactionID string `json:"transaction_id"` CommandID string `json:"command_id"` UserID int64 `json:"user_id"` AssetType string `json:"asset_type"` AvailableDelta int64 `json:"available_delta"` FrozenDelta int64 `json:"frozen_delta"` PayloadJSON string `json:"payload_json"` Status string `json:"status"` WorkerID string `json:"worker_id"` LockUntilMS *int64 `json:"lock_until_ms"` RetryCount int `json:"retry_count"` NextRetryAtMS *int64 `json:"next_retry_at_ms"` LastError *string `json:"last_error"` CreatedAtMS int64 `json:"created_at_ms"` UpdatedAtMS int64 `json:"updated_at_ms"` } // Verification is the complete local evidence set. SuccessCRC64 is computed // locally because _SUCCESS cannot contain its own checksum; a future purge gate // must compare all three CRC values with wallet_outbox_archive_receipts. type Verification struct { State string `json:"state"` Manifest Manifest `json:"manifest"` Records []Record `json:"-"` ManifestSHA256 string `json:"manifest_sha256"` ManifestCRC64 string `json:"manifest_crc64"` SuccessSHA256 string `json:"success_sha256"` SuccessCRC64 string `json:"success_crc64"` } // VerifyLocalFiles establishes the _SUCCESS gate before any restore DSN is // opened. The order is deliberate: an incomplete batch may have data/manifest // objects, but without a valid marker it can never reach a database write path. func VerifyLocalFiles(dataPath, manifestPath, successPath string, limits Limits) (Verification, error) { limits = normalizeLimits(limits) successBody, err := readBoundedFile(successPath, maxSuccessBytes) if err != nil { return Verification{}, fmt.Errorf("read _SUCCESS: %w", err) } var success Success if err := decodeStrictJSON(successBody, &success); err != nil { return Verification{}, fmt.Errorf("decode _SUCCESS: %w", err) } if success.Schema != archiveSchema || success.SchemaVersion != archiveSchemaVersion || success.State != successState || !validSHA256(success.ManifestSHA256) || !validCRC64(success.ManifestCRC64) || !validSHA256(success.BatchID) { return Verification{}, errors.New("_SUCCESS schema, state, batch_id, or manifest checksum is invalid") } manifestBody, err := readBoundedFile(manifestPath, maxManifestBytes) if err != nil { return Verification{}, fmt.Errorf("read manifest: %w", err) } manifestSHA256, manifestCRC64 := checksumBytes(manifestBody) if manifestSHA256 != success.ManifestSHA256 || manifestCRC64 != success.ManifestCRC64 { return Verification{}, errors.New("manifest does not match _SUCCESS SHA256/CRC64") } var manifest Manifest if err := decodeStrictJSON(manifestBody, &manifest); err != nil { return Verification{}, fmt.Errorf("decode manifest: %w", err) } if err := validateManifest(manifest, success, limits); err != nil { return Verification{}, err } records, err := verifyDataFile(dataPath, manifest, limits) if err != nil { return Verification{}, err } successSHA256, successCRC64 := checksumBytes(successBody) return Verification{ State: filesVerifiedState, Manifest: manifest, Records: records, ManifestSHA256: manifestSHA256, ManifestCRC64: manifestCRC64, SuccessSHA256: successSHA256, SuccessCRC64: successCRC64, }, nil } func normalizeLimits(limits Limits) Limits { defaults := DefaultLimits() if limits.MaxRows <= 0 { limits.MaxRows = defaults.MaxRows } if limits.MaxCompressedBytes <= 0 { limits.MaxCompressedBytes = defaults.MaxCompressedBytes } if limits.MaxUncompressedBytes <= 0 { limits.MaxUncompressedBytes = defaults.MaxUncompressedBytes } return limits } func validateManifest(manifest Manifest, success Success, limits Limits) error { if manifest.Schema != archiveSchema || manifest.SchemaVersion != archiveSchemaVersion || manifest.State != manifestState { return errors.New("manifest schema, version, or state is invalid") } if manifest.BatchID != success.BatchID || !validSHA256(manifest.BatchID) || strings.TrimSpace(manifest.AppCode) == "" || strings.TrimSpace(manifest.DataObjectKey) == "" { return errors.New("manifest identity does not match _SUCCESS") } if manifest.RowCount <= 0 || manifest.RowCount > limits.MaxRows { return fmt.Errorf("manifest row_count must be within [1,%d]", limits.MaxRows) } if manifest.CompressedBytes <= 0 || manifest.CompressedBytes > limits.MaxCompressedBytes || manifest.UncompressedBytes <= 0 || manifest.UncompressedBytes > limits.MaxUncompressedBytes { return errors.New("manifest byte counts exceed restore safety limits") } if !validSHA256(manifest.NDJSONSHA256) || !validSHA256(manifest.GzipSHA256) || !validCRC64(manifest.DataCRC64) { return errors.New("manifest data checksums are invalid") } if _, ok := new(big.Int).SetString(manifest.AvailableDeltaTotal, 10); !ok { return errors.New("manifest available_delta_total is invalid") } if _, ok := new(big.Int).SetString(manifest.FrozenDeltaTotal, 10); !ok { return errors.New("manifest frozen_delta_total is invalid") } if manifest.FirstCursor.UpdatedAtMS <= 0 || manifest.LastCursor.UpdatedAtMS <= 0 || strings.TrimSpace(manifest.FirstCursor.EventID) == "" || strings.TrimSpace(manifest.LastCursor.EventID) == "" { return errors.New("manifest cursor is incomplete") } deliveredDay := time.UnixMilli(manifest.FirstCursor.UpdatedAtMS).UTC().Format("2006-01-02") if time.UnixMilli(manifest.LastCursor.UpdatedAtMS).UTC().Format("2006-01-02") != deliveredDay { return errors.New("manifest crosses UTC delivered day") } expectedSuffix := path.Join("app_code="+manifest.AppCode, "delivered_day="+deliveredDay, "batch_id="+manifest.BatchID, "data.ndjson.gz") cleanObjectKey := path.Clean(manifest.DataObjectKey) if cleanObjectKey != expectedSuffix && !strings.HasSuffix(cleanObjectKey, "/"+expectedSuffix) { return errors.New("manifest data_object_key does not match app/day/batch identity") } return nil } func verifyDataFile(path string, manifest Manifest, limits Limits) ([]Record, error) { file, err := os.Open(strings.TrimSpace(path)) if err != nil { return nil, fmt.Errorf("open data object: %w", err) } defer file.Close() info, err := file.Stat() if err != nil { return nil, err } if info.Size() != manifest.CompressedBytes || info.Size() > limits.MaxCompressedBytes { return nil, errors.New("compressed data size does not match manifest") } gzipHash := sha256.New() gzipCRC := crc64.New(crc64.MakeTable(crc64.ECMA)) compressedReader := io.TeeReader(file, io.MultiWriter(gzipHash, gzipCRC)) gzipReader, err := gzip.NewReader(compressedReader) if err != nil { return nil, fmt.Errorf("open archived gzip: %w", err) } records, ndjsonSHA256, uncompressedBytes, totals, err := readAndValidateNDJSON(gzipReader, manifest, limits) closeErr := gzipReader.Close() _, drainErr := io.Copy(io.Discard, compressedReader) if err != nil { return nil, err } if closeErr != nil { return nil, fmt.Errorf("close archived gzip: %w", closeErr) } if drainErr != nil { return nil, fmt.Errorf("read archived gzip tail: %w", drainErr) } if hex.EncodeToString(gzipHash.Sum(nil)) != manifest.GzipSHA256 || strconv.FormatUint(gzipCRC.Sum64(), 10) != manifest.DataCRC64 { return nil, errors.New("data object SHA256/CRC64 does not match manifest") } if ndjsonSHA256 != manifest.NDJSONSHA256 || uncompressedBytes != manifest.UncompressedBytes { return nil, errors.New("NDJSON SHA256/byte count does not match manifest") } if totals.available.String() != manifest.AvailableDeltaTotal || totals.frozen.String() != manifest.FrozenDeltaTotal { return nil, errors.New("NDJSON control totals do not match manifest") } return records, nil } type controlTotals struct { available *big.Int frozen *big.Int } func readAndValidateNDJSON(reader io.Reader, manifest Manifest, limits Limits) ([]Record, string, int64, controlTotals, error) { buffered := bufio.NewReaderSize(reader, 64<<10) hash := sha256.New() records := make([]Record, 0, manifest.RowCount) seen := make(map[string]struct{}, manifest.RowCount) totals := controlTotals{available: new(big.Int), frozen: new(big.Int)} var byteCount int64 var minCreated, maxCreated int64 for { line, err := readRawLine(buffered, maxNDJSONLine) if err == io.EOF { break } if err != nil { return nil, "", 0, totals, err } byteCount += int64(len(line)) if byteCount > limits.MaxUncompressedBytes { return nil, "", 0, totals, errors.New("NDJSON exceeds uncompressed byte limit") } _, _ = hash.Write(line) var record Record if err := decodeStrictJSON(bytes.TrimSuffix(line, []byte{'\n'}), &record); err != nil { return nil, "", 0, totals, fmt.Errorf("decode NDJSON row %d: %w", len(records)+1, err) } if err := validateRecord(record, manifest, records, seen); err != nil { return nil, "", 0, totals, fmt.Errorf("validate NDJSON row %d: %w", len(records)+1, err) } if len(records) == 0 { minCreated, maxCreated = record.CreatedAtMS, record.CreatedAtMS } else { if record.CreatedAtMS < minCreated { minCreated = record.CreatedAtMS } if record.CreatedAtMS > maxCreated { maxCreated = record.CreatedAtMS } } totals.available.Add(totals.available, big.NewInt(record.AvailableDelta)) totals.frozen.Add(totals.frozen, big.NewInt(record.FrozenDelta)) records = append(records, record) if len(records) > limits.MaxRows || len(records) > manifest.RowCount { return nil, "", 0, totals, errors.New("NDJSON row count exceeds manifest or safety limit") } } if len(records) != manifest.RowCount { return nil, "", 0, totals, errors.New("NDJSON row count does not match manifest") } first, last := records[0], records[len(records)-1] if manifest.FirstCursor != (Cursor{UpdatedAtMS: first.UpdatedAtMS, EventID: first.EventID}) || manifest.LastCursor != (Cursor{UpdatedAtMS: last.UpdatedAtMS, EventID: last.EventID}) || manifest.MinUpdatedAtMS != first.UpdatedAtMS || manifest.MaxUpdatedAtMS != last.UpdatedAtMS { return nil, "", 0, totals, errors.New("NDJSON cursor bounds do not match manifest") } if manifest.MinCreatedAtMS != minCreated || manifest.MaxCreatedAtMS != maxCreated { return nil, "", 0, totals, errors.New("NDJSON created_at bounds do not match manifest") } ndjsonSHA256 := hex.EncodeToString(hash.Sum(nil)) batchHash := sha256.Sum256([]byte(strings.Join([]string{ manifest.AppCode, strconv.Itoa(len(records)), strconv.FormatInt(first.UpdatedAtMS, 10), first.EventID, strconv.FormatInt(last.UpdatedAtMS, 10), last.EventID, ndjsonSHA256, }, "\n"))) if hex.EncodeToString(batchHash[:]) != manifest.BatchID { return nil, "", 0, totals, errors.New("recomputed batch_id does not match manifest") } return records, ndjsonSHA256, byteCount, totals, nil } func validateRecord(record Record, manifest Manifest, prior []Record, seen map[string]struct{}) error { if record.AppCode != manifest.AppCode || record.Status != "delivered" || strings.TrimSpace(record.EventID) == "" || strings.TrimSpace(record.EventType) == "" || record.UpdatedAtMS <= 0 || record.CreatedAtMS <= 0 { return errors.New("row identity, state, or timestamp is invalid") } if !json.Valid([]byte(record.PayloadJSON)) { return errors.New("payload_json is not valid JSON") } if _, exists := seen[record.EventID]; exists { return errors.New("duplicate (app_code,event_id) archive PK") } seen[record.EventID] = struct{}{} if len(prior) > 0 { previous := prior[len(prior)-1] if record.UpdatedAtMS < previous.UpdatedAtMS || (record.UpdatedAtMS == previous.UpdatedAtMS && record.EventID <= previous.EventID) { return errors.New("rows are not strictly ordered by (updated_at_ms,event_id)") } } return nil } func readRawLine(reader *bufio.Reader, maxBytes int) ([]byte, error) { line := make([]byte, 0, 4096) for { fragment, err := reader.ReadSlice('\n') if len(line)+len(fragment) > maxBytes { return nil, fmt.Errorf("NDJSON line exceeds %d bytes", maxBytes) } line = append(line, fragment...) switch err { case nil: return line, nil case bufio.ErrBufferFull: continue case io.EOF: if len(line) == 0 { return nil, io.EOF } return nil, errors.New("NDJSON final row is missing newline terminator") default: return nil, err } } } func readBoundedFile(path string, maxBytes int64) ([]byte, error) { file, err := os.Open(strings.TrimSpace(path)) if err != nil { return nil, err } defer file.Close() body, err := io.ReadAll(io.LimitReader(file, maxBytes+1)) if err != nil { return nil, err } if int64(len(body)) > maxBytes || len(body) == 0 { return nil, errors.New("file is empty or exceeds safety limit") } return body, nil } func decodeStrictJSON(body []byte, destination any) error { decoder := json.NewDecoder(bytes.NewReader(body)) decoder.DisallowUnknownFields() if err := decoder.Decode(destination); err != nil { return err } var trailing any if err := decoder.Decode(&trailing); err != io.EOF { if err == nil { return errors.New("JSON contains more than one value") } return err } return nil } func checksumBytes(body []byte) (string, string) { sha := sha256.Sum256(body) crc := crc64.Update(0, crc64.MakeTable(crc64.ECMA), body) return hex.EncodeToString(sha[:]), strconv.FormatUint(crc, 10) } func validSHA256(value string) bool { if len(value) != sha256.Size*2 { return false } _, err := hex.DecodeString(value) return err == nil } func validCRC64(value string) bool { _, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64) return err == nil }