Export the reviewed allowlisted snapshot from private source commit 05928cebd01b586cf9e9d4b8c8537a7605a6068c. This records the exact candidate, bounded capacity result, stateful migration scratch requirement, authenticated batch identity proof, and immediate live acceptance evidence. AI-Assisted: OpenAI Codex Signed-off-by: Cole Speelman <crspeelman@gmail.com>
555 lines
19 KiB
Go
555 lines
19 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
package segment
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"gamertan.com/observatory/internal/model"
|
|
"github.com/klauspost/compress/zstd"
|
|
)
|
|
|
|
const (
|
|
MaxDecodedSegment = 64 << 20
|
|
MaxEncodedSegment = MaxDecodedSegment + 1<<20
|
|
metadataReadBatch = 128
|
|
)
|
|
|
|
type Store struct {
|
|
root string
|
|
}
|
|
|
|
type Committed struct {
|
|
Path string
|
|
Digest string
|
|
Compressed int64
|
|
Uncompressed int64
|
|
}
|
|
|
|
type Entry struct {
|
|
OrganizationID string
|
|
Committed Committed
|
|
Batch model.Batch
|
|
}
|
|
|
|
// Metadata identifies one immutable raw object without reading, checksumming,
|
|
// decompressing, or decoding its contents. It is safe to retain while walking
|
|
// a large store because it contains no telemetry records.
|
|
type Metadata struct {
|
|
OrganizationID string
|
|
SourceID string
|
|
StreamID string
|
|
Sequence uint64
|
|
Path string
|
|
Digest string
|
|
Compressed int64
|
|
}
|
|
|
|
func New(root string) (*Store, error) {
|
|
if !filepath.IsAbs(root) || filepath.Clean(root) != root {
|
|
return nil, errors.New("segment root must be an absolute clean path")
|
|
}
|
|
if err := os.MkdirAll(root, 0o700); err != nil {
|
|
return nil, fmt.Errorf("create segment root: %w", err)
|
|
}
|
|
info, err := os.Lstat(root)
|
|
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm()&0o077 != 0 {
|
|
return nil, errors.New("segment root must be a private non-symlink directory")
|
|
}
|
|
return &Store{root: root}, nil
|
|
}
|
|
|
|
func (s *Store) Commit(scope model.Scope, batch model.Batch) (Committed, error) {
|
|
if err := scope.Validate(); err != nil {
|
|
return Committed{}, err
|
|
}
|
|
raw, err := json.Marshal(batch)
|
|
if err != nil {
|
|
return Committed{}, fmt.Errorf("encode batch: %w", err)
|
|
}
|
|
if len(raw) > MaxDecodedSegment {
|
|
return Committed{}, errors.New("decoded segment exceeds limit")
|
|
}
|
|
enc, err := zstd.NewWriter(nil, zstd.WithEncoderConcurrency(1), zstd.WithEncoderLevel(zstd.SpeedBetterCompression))
|
|
if err != nil {
|
|
return Committed{}, fmt.Errorf("create compressor: %w", err)
|
|
}
|
|
compressed := enc.EncodeAll(raw, nil)
|
|
enc.Close()
|
|
if len(compressed) > MaxEncodedSegment {
|
|
return Committed{}, errors.New("encoded segment exceeds limit")
|
|
}
|
|
sum := sha256.Sum256(compressed)
|
|
digest := hex.EncodeToString(sum[:])
|
|
dir := filepath.Join(s.root, "raw", scope.OrganizationID, batch.SourceID, batch.StreamID)
|
|
if err := ensurePrivateDirectoryChain(s.root, dir); err != nil {
|
|
return Committed{}, err
|
|
}
|
|
name := fmt.Sprintf("%020d-%s.zst", batch.Sequence, digest)
|
|
final := filepath.Join(dir, name)
|
|
if existing, err := readRegular(final, MaxEncodedSegment); err == nil {
|
|
if bytes.Equal(existing, compressed) {
|
|
return Committed{Path: final, Digest: digest, Compressed: int64(len(compressed)), Uncompressed: int64(len(raw))}, nil
|
|
}
|
|
return Committed{}, errors.New("existing segment digest collision")
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return Committed{}, fmt.Errorf("inspect existing segment: %w", err)
|
|
}
|
|
tmp, err := os.CreateTemp(dir, ".segment-*")
|
|
if err != nil {
|
|
return Committed{}, fmt.Errorf("create segment temporary file: %w", err)
|
|
}
|
|
tmpName := tmp.Name()
|
|
cleanup := func() { _ = os.Remove(tmpName) }
|
|
defer cleanup()
|
|
if err := tmp.Chmod(0o600); err != nil {
|
|
_ = tmp.Close()
|
|
return Committed{}, fmt.Errorf("set segment mode: %w", err)
|
|
}
|
|
if _, err := tmp.Write(compressed); err != nil {
|
|
_ = tmp.Close()
|
|
return Committed{}, fmt.Errorf("write segment: %w", err)
|
|
}
|
|
if err := tmp.Sync(); err != nil {
|
|
_ = tmp.Close()
|
|
return Committed{}, fmt.Errorf("sync segment: %w", err)
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return Committed{}, fmt.Errorf("close segment: %w", err)
|
|
}
|
|
if err := os.Rename(tmpName, final); err != nil {
|
|
return Committed{}, fmt.Errorf("commit segment: %w", err)
|
|
}
|
|
d, err := os.Open(dir)
|
|
if err != nil {
|
|
return Committed{}, fmt.Errorf("open segment directory: %w", err)
|
|
}
|
|
if err := d.Sync(); err != nil {
|
|
_ = d.Close()
|
|
return Committed{}, fmt.Errorf("sync segment directory: %w", err)
|
|
}
|
|
if err := d.Close(); err != nil {
|
|
return Committed{}, fmt.Errorf("close segment directory: %w", err)
|
|
}
|
|
return Committed{Path: final, Digest: digest, Compressed: int64(len(compressed)), Uncompressed: int64(len(raw))}, nil
|
|
}
|
|
|
|
func (s *Store) Read(path, expectedDigest string) (model.Batch, error) {
|
|
var batch model.Batch
|
|
cleanRoot := filepath.Clean(s.root) + string(os.PathSeparator)
|
|
cleanPath := filepath.Clean(path)
|
|
if !strings.HasPrefix(cleanPath, cleanRoot) {
|
|
return batch, errors.New("segment path escapes store")
|
|
}
|
|
if err := validatePrivateDirectoryChain(s.root, filepath.Dir(cleanPath)); err != nil {
|
|
return batch, err
|
|
}
|
|
b, err := readRegular(cleanPath, MaxEncodedSegment)
|
|
if err != nil {
|
|
return batch, fmt.Errorf("read segment: %w", err)
|
|
}
|
|
sum := sha256.Sum256(b)
|
|
if hex.EncodeToString(sum[:]) != expectedDigest {
|
|
return batch, errors.New("segment checksum mismatch")
|
|
}
|
|
dec, err := zstd.NewReader(bytes.NewReader(b), zstd.WithDecoderConcurrency(1), zstd.WithDecoderMaxMemory(MaxDecodedSegment))
|
|
if err != nil {
|
|
return batch, fmt.Errorf("create decompressor: %w", err)
|
|
}
|
|
decompressed, err := io.ReadAll(io.LimitReader(dec, MaxDecodedSegment+1))
|
|
dec.Close()
|
|
if err != nil {
|
|
return batch, fmt.Errorf("decompress segment: %w", err)
|
|
}
|
|
if len(decompressed) > MaxDecodedSegment {
|
|
return batch, errors.New("decoded segment exceeds limit")
|
|
}
|
|
decoder := json.NewDecoder(bytes.NewReader(decompressed))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&batch); err != nil {
|
|
return batch, fmt.Errorf("decode segment: %w", err)
|
|
}
|
|
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
|
return batch, errors.New("segment contains trailing JSON")
|
|
}
|
|
return batch, nil
|
|
}
|
|
|
|
// ReadEntry validates and decodes exactly one object previously returned by
|
|
// WalkMetadata. Callers can therefore keep startup discovery bounded and pay
|
|
// the decompression cost only for an object that actually needs recovery.
|
|
func (s *Store) ReadEntry(metadata Metadata) (Entry, error) {
|
|
actual, err := s.metadata(metadata.Path)
|
|
if err != nil {
|
|
return Entry{}, err
|
|
}
|
|
if actual != metadata {
|
|
return Entry{}, errors.New("segment metadata changed before decode")
|
|
}
|
|
batch, err := s.Read(metadata.Path, metadata.Digest)
|
|
if err != nil {
|
|
return Entry{}, err
|
|
}
|
|
if batch.SourceID != metadata.SourceID || batch.StreamID != metadata.StreamID || batch.Sequence != metadata.Sequence {
|
|
return Entry{}, errors.New("segment path does not match batch identity")
|
|
}
|
|
raw, err := json.Marshal(batch)
|
|
if err != nil {
|
|
return Entry{}, fmt.Errorf("measure decoded segment: %w", err)
|
|
}
|
|
return Entry{
|
|
OrganizationID: metadata.OrganizationID,
|
|
Committed: Committed{
|
|
Path: metadata.Path,
|
|
Digest: metadata.Digest,
|
|
Compressed: metadata.Compressed,
|
|
Uncompressed: int64(len(raw)),
|
|
},
|
|
Batch: batch,
|
|
}, nil
|
|
}
|
|
|
|
// Delete removes one already-retired segment after verifying that the path,
|
|
// filename, file type, and content digest still identify the exact committed
|
|
// object. A missing object is an idempotent success for crash recovery.
|
|
func (s *Store) Delete(path, expectedDigest string) error {
|
|
if len(expectedDigest) != 64 {
|
|
return errors.New("segment deletion digest is invalid")
|
|
}
|
|
rawRoot := filepath.Join(filepath.Clean(s.root), "raw") + string(os.PathSeparator)
|
|
coldRoot := filepath.Join(filepath.Clean(s.root), "cold") + string(os.PathSeparator)
|
|
cleanPath := filepath.Clean(path)
|
|
if (!strings.HasPrefix(cleanPath, rawRoot) && !strings.HasPrefix(cleanPath, coldRoot)) || !strings.HasSuffix(filepath.Base(cleanPath), "-"+expectedDigest+".zst") {
|
|
return errors.New("segment deletion path is invalid")
|
|
}
|
|
if err := validatePrivateDirectoryChain(s.root, filepath.Dir(cleanPath)); err != nil {
|
|
return err
|
|
}
|
|
if _, err := os.Lstat(cleanPath); errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
} else if err != nil {
|
|
return fmt.Errorf("inspect retired segment: %w", err)
|
|
}
|
|
if _, err := s.Read(cleanPath, expectedDigest); err != nil {
|
|
return fmt.Errorf("verify retired segment: %w", err)
|
|
}
|
|
if err := os.Remove(cleanPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("remove retired segment: %w", err)
|
|
}
|
|
directory, err := os.Open(filepath.Dir(cleanPath))
|
|
if err != nil {
|
|
return fmt.Errorf("open retired segment directory: %w", err)
|
|
}
|
|
if err = directory.Sync(); err != nil {
|
|
_ = directory.Close()
|
|
return fmt.Errorf("sync retired segment directory: %w", err)
|
|
}
|
|
if err = directory.Close(); err != nil {
|
|
return fmt.Errorf("close retired segment directory: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MoveToCold atomically relocates one verified hot object beneath the cold
|
|
// archive. It is idempotent across a crash after rename: when the source is
|
|
// absent, the exact destination must already exist and match its digest.
|
|
func (s *Store) MoveToCold(path, target, expectedDigest string) error {
|
|
if len(expectedDigest) != 64 {
|
|
return errors.New("segment archive digest is invalid")
|
|
}
|
|
cleanPath, cleanTarget := filepath.Clean(path), filepath.Clean(target)
|
|
rawRoot := filepath.Join(filepath.Clean(s.root), "raw") + string(os.PathSeparator)
|
|
coldRoot := filepath.Join(filepath.Clean(s.root), "cold") + string(os.PathSeparator)
|
|
if !strings.HasPrefix(cleanPath, rawRoot) || !strings.HasPrefix(cleanTarget, coldRoot) || filepath.Base(cleanPath) != filepath.Base(cleanTarget) || !strings.HasSuffix(filepath.Base(cleanPath), "-"+expectedDigest+".zst") {
|
|
return errors.New("segment archive path is invalid")
|
|
}
|
|
if err := validatePrivateDirectoryChain(s.root, filepath.Dir(cleanPath)); err != nil {
|
|
return err
|
|
}
|
|
if err := ensurePrivateDirectoryChain(s.root, filepath.Dir(cleanTarget)); err != nil {
|
|
return err
|
|
}
|
|
if _, err := os.Lstat(cleanPath); errors.Is(err, os.ErrNotExist) {
|
|
if _, readErr := s.Read(cleanTarget, expectedDigest); readErr != nil {
|
|
return fmt.Errorf("recover archived segment: %w", readErr)
|
|
}
|
|
for _, directory := range []string{filepath.Dir(cleanTarget), filepath.Dir(cleanPath)} {
|
|
if syncErr := syncDirectory(directory); syncErr != nil {
|
|
return syncErr
|
|
}
|
|
}
|
|
return nil
|
|
} else if err != nil {
|
|
return fmt.Errorf("inspect hot segment: %w", err)
|
|
}
|
|
if _, err := os.Lstat(cleanTarget); err == nil {
|
|
return errors.New("cold segment destination already exists")
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("inspect cold segment destination: %w", err)
|
|
}
|
|
if _, err := s.Read(cleanPath, expectedDigest); err != nil {
|
|
return fmt.Errorf("verify hot segment: %w", err)
|
|
}
|
|
if err := os.Rename(cleanPath, cleanTarget); err != nil {
|
|
return fmt.Errorf("archive segment: %w", err)
|
|
}
|
|
for _, directory := range []string{filepath.Dir(cleanTarget), filepath.Dir(cleanPath)} {
|
|
if err := syncDirectory(directory); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func syncDirectory(path string) error {
|
|
directory, err := os.Open(path)
|
|
if err != nil {
|
|
return fmt.Errorf("open segment directory: %w", err)
|
|
}
|
|
if err = directory.Sync(); err != nil {
|
|
_ = directory.Close()
|
|
return fmt.Errorf("sync segment directory: %w", err)
|
|
}
|
|
if err = directory.Close(); err != nil {
|
|
return fmt.Errorf("close segment directory: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validatePrivateDirectoryChain(root, directory string) error {
|
|
root = filepath.Clean(root)
|
|
directory = filepath.Clean(directory)
|
|
relative, err := filepath.Rel(root, directory)
|
|
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(os.PathSeparator)) {
|
|
return errors.New("segment directory escapes store")
|
|
}
|
|
candidates := []string{root}
|
|
current := root
|
|
if relative != "." {
|
|
for _, component := range strings.Split(relative, string(os.PathSeparator)) {
|
|
if component == "" || component == "." || component == ".." {
|
|
return errors.New("segment directory path is invalid")
|
|
}
|
|
current = filepath.Join(current, component)
|
|
candidates = append(candidates, current)
|
|
}
|
|
}
|
|
for _, candidate := range candidates {
|
|
info, inspectErr := os.Lstat(candidate)
|
|
if inspectErr != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm()&0o077 != 0 {
|
|
return errors.New("segment directory chain must be private and non-symlinked")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ensurePrivateDirectoryChain(root, directory string) error {
|
|
root = filepath.Clean(root)
|
|
directory = filepath.Clean(directory)
|
|
relative, err := filepath.Rel(root, directory)
|
|
if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(os.PathSeparator)) {
|
|
return errors.New("segment directory path is invalid")
|
|
}
|
|
current := root
|
|
for _, component := range strings.Split(relative, string(os.PathSeparator)) {
|
|
if component == "" || component == "." || component == ".." {
|
|
return errors.New("segment directory path is invalid")
|
|
}
|
|
current = filepath.Join(current, component)
|
|
info, inspectErr := os.Lstat(current)
|
|
if errors.Is(inspectErr, os.ErrNotExist) {
|
|
if inspectErr = os.Mkdir(current, 0o700); inspectErr != nil && !errors.Is(inspectErr, os.ErrExist) {
|
|
return fmt.Errorf("create segment directory: %w", inspectErr)
|
|
}
|
|
info, inspectErr = os.Lstat(current)
|
|
}
|
|
if inspectErr != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm()&0o077 != 0 {
|
|
return errors.New("segment directory chain must be private and non-symlinked")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func readRegular(path string, maximum int) ([]byte, error) {
|
|
before, err := os.Lstat(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !before.Mode().IsRegular() || before.Mode()&os.ModeSymlink != 0 || before.Size() > int64(maximum) {
|
|
return nil, errors.New("segment is not a bounded regular file")
|
|
}
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer file.Close()
|
|
after, err := file.Stat()
|
|
if err != nil || !after.Mode().IsRegular() || !os.SameFile(before, after) {
|
|
return nil, errors.New("segment changed during open")
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(file, int64(maximum)+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(body) > maximum {
|
|
return nil, errors.New("encoded segment exceeds limit")
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
// WalkMetadata derives bounded committed-object identities from the filesystem
|
|
// so a crash after rename but before control-database bookkeeping cannot hide
|
|
// a segment. It deliberately does not read or decode segment contents.
|
|
func (s *Store) WalkMetadata(visit func(Metadata) error) error {
|
|
if visit == nil {
|
|
return errors.New("segment metadata visitor is required")
|
|
}
|
|
base := filepath.Join(s.root, "raw")
|
|
if _, err := os.Lstat(base); errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
} else if err != nil {
|
|
return fmt.Errorf("inspect raw segment root: %w", err)
|
|
}
|
|
if err := validatePrivateDirectoryChain(s.root, base); err != nil {
|
|
return err
|
|
}
|
|
err := s.walkMetadataDirectory(base, 0, visit)
|
|
if err != nil {
|
|
return fmt.Errorf("walk raw segments: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) walkMetadataDirectory(directoryPath string, depth int, visit func(Metadata) error) error {
|
|
directory, err := os.Open(directoryPath)
|
|
if err != nil {
|
|
return fmt.Errorf("open raw segment directory: %w", err)
|
|
}
|
|
defer directory.Close()
|
|
info, err := directory.Stat()
|
|
if err != nil || !info.IsDir() || info.Mode().Perm()&0o077 != 0 {
|
|
return errors.New("raw segment directory must be private")
|
|
}
|
|
for {
|
|
entries, readErr := directory.ReadDir(metadataReadBatch)
|
|
for _, entry := range entries {
|
|
path := filepath.Join(directoryPath, entry.Name())
|
|
if entry.Type()&os.ModeSymlink != 0 {
|
|
return errors.New("raw segment tree contains a symlink")
|
|
}
|
|
if entry.IsDir() {
|
|
if depth >= 3 {
|
|
return errors.New("raw segment tree has invalid depth")
|
|
}
|
|
if err = s.walkMetadataDirectory(path, depth+1, visit); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
if !strings.HasSuffix(entry.Name(), ".zst") {
|
|
continue
|
|
}
|
|
metadata, metadataErr := s.metadata(path)
|
|
if metadataErr != nil {
|
|
return metadataErr
|
|
}
|
|
if visitErr := visit(metadata); visitErr != nil {
|
|
return visitErr
|
|
}
|
|
}
|
|
if errors.Is(readErr, io.EOF) {
|
|
break
|
|
}
|
|
if readErr != nil {
|
|
return fmt.Errorf("read raw segment directory: %w", readErr)
|
|
}
|
|
}
|
|
if err = directory.Close(); err != nil {
|
|
return fmt.Errorf("close raw segment directory: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) metadata(path string) (Metadata, error) {
|
|
base := filepath.Join(s.root, "raw")
|
|
cleanPath := filepath.Clean(path)
|
|
relative, err := filepath.Rel(base, cleanPath)
|
|
if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(os.PathSeparator)) {
|
|
return Metadata{}, errors.New("segment path escapes raw store")
|
|
}
|
|
pathParts := strings.Split(filepath.ToSlash(relative), "/")
|
|
if len(pathParts) != 4 {
|
|
return Metadata{}, errors.New("raw segment path has invalid depth")
|
|
}
|
|
if err = model.ValidateSourceID(pathParts[0]); err != nil {
|
|
return Metadata{}, errors.New("raw segment organization is invalid")
|
|
}
|
|
if err = model.ValidateSourceID(pathParts[1]); err != nil {
|
|
return Metadata{}, errors.New("raw segment source is invalid")
|
|
}
|
|
if err = model.ValidateStreamID(pathParts[2]); err != nil {
|
|
return Metadata{}, errors.New("raw segment stream is invalid")
|
|
}
|
|
name := strings.TrimSuffix(pathParts[3], ".zst")
|
|
parts := strings.Split(name, "-")
|
|
if len(parts) != 2 || len(parts[0]) != 20 || len(parts[1]) != 64 || strings.ToLower(parts[1]) != parts[1] {
|
|
return Metadata{}, fmt.Errorf("invalid segment filename %q", pathParts[3])
|
|
}
|
|
sequence, err := strconv.ParseUint(parts[0], 10, 64)
|
|
if err != nil || sequence == 0 {
|
|
return Metadata{}, fmt.Errorf("invalid segment sequence %q", parts[0])
|
|
}
|
|
if _, err = hex.DecodeString(parts[1]); err != nil {
|
|
return Metadata{}, errors.New("segment filename digest is invalid")
|
|
}
|
|
if err = validatePrivateDirectoryChain(s.root, filepath.Dir(cleanPath)); err != nil {
|
|
return Metadata{}, err
|
|
}
|
|
info, err := os.Lstat(cleanPath)
|
|
if err != nil {
|
|
return Metadata{}, fmt.Errorf("inspect raw segment: %w", err)
|
|
}
|
|
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm()&0o077 != 0 || info.Size() > MaxEncodedSegment {
|
|
return Metadata{}, errors.New("raw segment must be a private bounded regular file")
|
|
}
|
|
return Metadata{
|
|
OrganizationID: pathParts[0],
|
|
SourceID: pathParts[1],
|
|
StreamID: pathParts[2],
|
|
Sequence: sequence,
|
|
Path: cleanPath,
|
|
Digest: parts[1],
|
|
Compressed: info.Size(),
|
|
}, nil
|
|
}
|
|
|
|
// List retains the complete decoding API for explicit forensic callers and
|
|
// tests. Startup recovery uses WalkMetadata and decodes only missing work.
|
|
func (s *Store) List() ([]Entry, error) {
|
|
var entries []Entry
|
|
err := s.WalkMetadata(func(metadata Metadata) error {
|
|
entry, readErr := s.ReadEntry(metadata)
|
|
if readErr != nil {
|
|
return readErr
|
|
}
|
|
entries = append(entries, entry)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sort.Slice(entries, func(i, j int) bool { return entries[i].Committed.Path < entries[j].Committed.Path })
|
|
return entries, nil
|
|
}
|