Skip to content

Commit e38e2ba

Browse files
authored
Merge branch 'FiloSottile:main' into empty-backends-file
2 parents dd51349 + 41992bd commit e38e2ba

2 files changed

Lines changed: 208 additions & 0 deletions

File tree

NEWS.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
## v0.8.0
2+
3+
### torchwood
4+
5+
- Added `TileFS`, a `TileReader` implementation that reads tiles from a
6+
filesystem. Supports optional gzip decompression of data tiles.
7+
8+
- Added `TileArchiveFS`, an `fs.FS` implementation that reads files from a
9+
set of zip archives.
10+
111
## v0.7.0
212

313
Updated golang.org/x/... dependencies.

tlogclient.go

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
package torchwood
22

33
import (
4+
"archive/zip"
45
"bytes"
6+
"compress/gzip"
57
"context"
68
"errors"
79
"fmt"
810
"io"
11+
"io/fs"
912
"iter"
1013
"log/slog"
1114
"math"
@@ -691,3 +694,198 @@ func (c *PermanentCache) SaveTiles(tiles []tlog.Tile, data [][]byte) {
691694
func (c *PermanentCache) ReadEndpoint(ctx context.Context, path string) (data []byte, err error) {
692695
return c.tr.ReadEndpoint(ctx, path)
693696
}
697+
698+
// TileFS is a [TileReader] that reads tiles from a [fs.FS].
699+
type TileFS struct {
700+
fs fs.FS
701+
tilePath func(tlog.Tile) string
702+
gzip bool
703+
}
704+
705+
// NewTileFS creates a new [TileFS] that reads tiles from the given [fs.FS].
706+
// By default, it expects tiles to be laid out according to c2sp.org/tlog-tiles.
707+
func NewTileFS(f fs.FS, opts ...TileFSOption) (*TileFS, error) {
708+
tf := &TileFS{fs: f}
709+
for _, opt := range opts {
710+
opt(tf)
711+
}
712+
if tf.tilePath == nil {
713+
tf.tilePath = TilePath
714+
}
715+
return tf, nil
716+
}
717+
718+
// TileFSOption is a function that configures a [TileFS].
719+
type TileFSOption func(*TileFS)
720+
721+
// WithTileFSTilePath configures the function used to generate the tile path
722+
// from a [tlog.Tile]. By default, TileFS uses the c2sp.org/tlog-tiles scheme
723+
// implemented by [TilePath]. For the go.dev/design/25530-sumdb scheme, use
724+
// [tlog.Tile.Path]. For the c2sp.org/static-ct-api scheme, use
725+
// [filippo.io/sunlight.TilePath].
726+
func WithTileFSTilePath(tilePath func(tlog.Tile) string) TileFSOption {
727+
return func(f *TileFS) {
728+
f.tilePath = tilePath
729+
}
730+
}
731+
732+
// WithGzipDataTiles configures the TileFS to transparently decompress
733+
// gzip-compressed data tiles.
734+
func WithGzipDataTiles() TileFSOption {
735+
return func(f *TileFS) {
736+
f.gzip = true
737+
}
738+
}
739+
740+
// ReadTiles implements [TileReader].
741+
func (f *TileFS) ReadTiles(ctx context.Context, tiles []tlog.Tile) (data [][]byte, err error) {
742+
data = make([][]byte, len(tiles))
743+
for i, t := range tiles {
744+
if t.H != TileHeight {
745+
return nil, fmt.Errorf("unexpected tile height %d", t.H)
746+
}
747+
path := f.tilePath(t)
748+
d, err := fs.ReadFile(f.fs, path)
749+
if err != nil {
750+
return nil, fmt.Errorf("failed to read tile %s: %w", path, err)
751+
}
752+
if f.gzip && t.L == -1 {
753+
gr, err := gzip.NewReader(bytes.NewReader(d))
754+
if err != nil {
755+
return nil, fmt.Errorf("failed to create gzip reader for tile %s: %w", path, err)
756+
}
757+
decompressed, err := io.ReadAll(gr)
758+
if err != nil {
759+
return nil, fmt.Errorf("failed to decompress tile %s: %w", path, err)
760+
}
761+
if err := gr.Close(); err != nil {
762+
return nil, fmt.Errorf("failed to close gzip reader for tile %s: %w", path, err)
763+
}
764+
d = decompressed
765+
}
766+
data[i] = d
767+
}
768+
return data, nil
769+
}
770+
771+
// ReadEndpoint fetches an arbitrary path.
772+
func (f *TileFS) ReadEndpoint(ctx context.Context, path string) (data []byte, err error) {
773+
// Callers should use [os.Root] as a more robust protection, and FS
774+
// implementations should check ValidPath, but avoid the most trivial
775+
// directory traversal here as well.
776+
if !fs.ValidPath(path) {
777+
return nil, fmt.Errorf("invalid path %q", path)
778+
}
779+
return fs.ReadFile(f.fs, path)
780+
}
781+
782+
// SaveTiles implements [TileReader]. It does nothing.
783+
func (f *TileFS) SaveTiles(tiles []tlog.Tile, data [][]byte) {}
784+
785+
// TileArchiveFS is an [fs.FS] that reads tiles and accessory files from a
786+
// collection of zip files, numbered 000.zip, 001.zip, ...
787+
//
788+
// Each zip file contains the corresponding level 2 tile, and all the full tiles
789+
// below it. All other files (higher-level tiles, partial tiles on the right
790+
// edge, checkpoint, etc.) are expected to be present in every zip file.
791+
//
792+
// It supports both c2sp.org/tlog-tiles and c2sp.org/static-ct-api tile layouts,
793+
// but not go.dev/design/25530-sumdb.
794+
//
795+
// See also https://github.com/geomys/ct-archive/blob/main/README.md#archival-format.
796+
type TileArchiveFS struct {
797+
zips fs.FS
798+
799+
// cachedReader, if not nil, is the cachedIndex-th zip file.
800+
cachedReader *zip.Reader
801+
cachedFile fs.File
802+
cachedIndex int
803+
}
804+
805+
// NewTileArchiveFS creates a new [TileArchiveFS] that reads zip files from
806+
// the root of the given [fs.FS]. f.Open must return files that implement
807+
// [io.ReaderAt].
808+
func NewTileArchiveFS(f fs.FS) *TileArchiveFS {
809+
return &TileArchiveFS{zips: f}
810+
}
811+
812+
// Open implements [fs.FS].
813+
func (tf *TileArchiveFS) Open(name string) (fs.File, error) {
814+
var zipIndex int
815+
t, ok := parseMultiTilePath(name)
816+
switch {
817+
case !ok || t.L > 2 || t.W != TileWidth:
818+
// All zip files contain this file, so use the cached one, if any.
819+
zipIndex = tf.cachedIndex
820+
case t.L == 2:
821+
zipIndex = int(t.N)
822+
case t.L == 1:
823+
zipIndex = int(t.N / TileWidth)
824+
default: // levels 0 and -1
825+
zipIndex = int(t.N / (TileWidth * TileWidth))
826+
}
827+
zr, err := tf.zipReader(zipIndex)
828+
if err != nil {
829+
return nil, &fs.PathError{Op: "open", Path: name, Err: err}
830+
}
831+
f, err := zr.Open(name)
832+
if err != nil {
833+
return nil, &fs.PathError{Op: "open", Path: name, Err: fmt.Errorf("reading from %03d.zip: %w", zipIndex, err)}
834+
}
835+
return f, nil
836+
}
837+
838+
func parseMultiTilePath(path string) (tlog.Tile, bool) {
839+
// Convert c2sp.org/static-ct-api to c2sp.org/tlog-tiles.
840+
if rest, ok := strings.CutPrefix(path, "tile/data/"); ok {
841+
path = "tile/entries/" + rest
842+
}
843+
tile, err := ParseTilePath(path)
844+
if err != nil {
845+
return tlog.Tile{}, false
846+
}
847+
return tile, true
848+
}
849+
850+
func (tf *TileArchiveFS) zipReader(index int) (*zip.Reader, error) {
851+
if tf.cachedReader != nil && tf.cachedIndex == index {
852+
return tf.cachedReader, nil
853+
}
854+
if tf.cachedFile != nil {
855+
if err := tf.cachedFile.Close(); err != nil {
856+
return nil, fmt.Errorf("failed to close previous zip file: %w", err)
857+
}
858+
}
859+
zipPath := fmt.Sprintf("%03d.zip", index)
860+
f, err := tf.zips.Open(zipPath)
861+
if err != nil {
862+
return nil, fmt.Errorf("failed to open zip file: %w", err)
863+
}
864+
at, ok := f.(io.ReaderAt)
865+
if !ok {
866+
return nil, &fs.PathError{Op: "open", Path: zipPath, Err: errors.New("zip file does not implement io.ReaderAt")}
867+
}
868+
fi, err := f.Stat()
869+
if err != nil {
870+
return nil, fmt.Errorf("failed to stat zip file %q: %w", zipPath, err)
871+
}
872+
zr, err := zip.NewReader(at, fi.Size())
873+
if err != nil {
874+
return nil, &fs.PathError{Op: "open", Path: zipPath, Err: fmt.Errorf("failed to read zip file: %w", err)}
875+
}
876+
tf.cachedReader = zr
877+
tf.cachedFile = f
878+
tf.cachedIndex = index
879+
return zr, nil
880+
}
881+
882+
func (tf *TileArchiveFS) Close() error {
883+
if tf.cachedFile != nil {
884+
err := tf.cachedFile.Close()
885+
tf.cachedReader = nil
886+
tf.cachedFile = nil
887+
tf.cachedIndex = 0
888+
return err
889+
}
890+
return nil
891+
}

0 commit comments

Comments
 (0)