|
1 | 1 | package torchwood |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "archive/zip" |
4 | 5 | "bytes" |
| 6 | + "compress/gzip" |
5 | 7 | "context" |
6 | 8 | "errors" |
7 | 9 | "fmt" |
8 | 10 | "io" |
| 11 | + "io/fs" |
9 | 12 | "iter" |
10 | 13 | "log/slog" |
11 | 14 | "math" |
@@ -691,3 +694,198 @@ func (c *PermanentCache) SaveTiles(tiles []tlog.Tile, data [][]byte) { |
691 | 694 | func (c *PermanentCache) ReadEndpoint(ctx context.Context, path string) (data []byte, err error) { |
692 | 695 | return c.tr.ReadEndpoint(ctx, path) |
693 | 696 | } |
| 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