From 84da43998f7cc42cc72652333ac6e3d293abb23e Mon Sep 17 00:00:00 2001 From: tsne Date: Mon, 6 Jul 2026 16:52:20 +0200 Subject: initial --- internal/source/source.go | 213 +++++++++++++++++++++++++++++++++++++++++ internal/source/source_test.go | 95 ++++++++++++++++++ 2 files changed, 308 insertions(+) create mode 100644 internal/source/source.go create mode 100644 internal/source/source_test.go (limited to 'internal/source') diff --git a/internal/source/source.go b/internal/source/source.go new file mode 100644 index 0000000..f2f99b4 --- /dev/null +++ b/internal/source/source.go @@ -0,0 +1,213 @@ +// Package source provides a sequential, single-pass iterator over release +// entries (regular files to upload). TarGzSource reads a tar.gz; a directory +// source may be added later behind the same interface. +package source + +import ( + "archive/tar" + "compress/gzip" + "context" + "errors" + "fmt" + "io" + "os" + "path" + "strings" + + "tsne.dev/hopper/internal/log" +) + +// Entry is a single regular file emitted by a Source. +type Entry struct { + Path string // cleaned, archive-relative key (no prefix applied). + Size int + IsMutable bool // true for mutable entry points (*.html, case-insensitive). + + content io.Reader // unexported; live source stream for THIS entry. +} + +// ReadContent reads the current entry's bytes fully into memory. +// On context cancellation it returns the context's cause promptly instead +// of finishing a large read. It is valid ONLY for the current entry and +// must be called before the next Source.Next(). +func (e Entry) ReadContent(ctx context.Context) ([]byte, error) { + const chunkSize = 512 << 10 // 512 KiB + + initialSize := e.Size + if initialSize == 0 { + initialSize = 4096 + } + + buf := make([]byte, initialSize) + off := 0 + for { + if err := context.Cause(ctx); err != nil { + return nil, err + } + + if len(buf)-off < chunkSize { + newbuf := make([]byte, max(2*len(buf), len(buf)+chunkSize)) + copy(newbuf, buf[:off]) + buf = newbuf + } + + read, err := e.content.Read(buf[off : off+chunkSize]) + if read > 0 { + off += read + } + if err != nil { + if errors.Is(err, io.EOF) { + return buf[:off], nil + } + return nil, err + } + } +} + +// Source is a sequential, single-pass iterator. Returns io.EOF when exhausted. +type Source interface { + Next() (Entry, error) + Close() error +} + +// TarGzSource streams compress/gzip -> archive/tar entries one at a time. +type TarGzSource struct { + f *os.File + gz *gzip.Reader + tr *tar.Reader + root string +} + +// NewTarGzSource opens the given tar.gz archive. The given root selects +// the subtree to emit and is stripped from each selected entry. If root +// is empty all entries are selected. +// +// Note: `root` must arrive pre-normalized (no leading/trailing slashes, +// cleaned of "."/".."). The cli package owns root normalization. +func NewTarGzSource(archivePath string, root string) (*TarGzSource, error) { + f, err := os.Open(archivePath) + if err != nil { + return nil, fmt.Errorf("open archive: %w", err) + } + gz, err := gzip.NewReader(f) + if err != nil { + f.Close() + return nil, fmt.Errorf("gzip: %w", err) + } + + return &TarGzSource{ + f: f, + gz: gz, + tr: tar.NewReader(gz), + root: root, + }, nil +} + +// Next returns the next regular-file entry, filtering by entry type and +// applying path-safety rules. Returns io.EOF when exhausted. +// +// Entry-type handling: regular files are emitted; directories are silently +// ignored; symlinks/hardlinks/devices/FIFOs/etc. are skipped with a warning +// (never fatal). +func (s *TarGzSource) Next() (Entry, error) { + for { + hdr, err := s.tr.Next() + if err != nil { + return Entry{}, err // includes io.EOF + } + + switch hdr.Typeflag { + case tar.TypeReg: + // emit below + case tar.TypeDir: + continue // silently ignored + default: + log.Warn("skipping non-regular source entry %q (type %q)", hdr.Name, string(hdr.Typeflag)) + continue + } + + cleaned, err := cleanArchivePath(hdr.Name) + if err != nil { + return Entry{}, err + } + + path, ok := s.selectPath(cleaned) + if !ok { + continue + } + + return Entry{ + Path: path, + Size: int(hdr.Size), + IsMutable: strings.HasSuffix(strings.ToLower(path), ".html"), + content: s.tr, + }, nil + } +} + +// Close releases the underlying resources. +func (s *TarGzSource) Close() error { + gzErr := s.gz.Close() + fErr := s.f.Close() + if gzErr != nil { + return gzErr + } + return fErr +} + +func (s *TarGzSource) selectPath(path string) (string, bool) { + if s.root == "" { + return path, true + } + prefix := s.root + "/" + if !strings.HasPrefix(path, prefix) { + return "", false + } + return path[len(prefix):], true +} + +// cleanArchivePath validates and cleans a raw archive path: +// - absolute path -> hard error; +// - cleaned path escaping the root (starts with "..") -> hard error; +// - stays within root but contained a ".." segment -> emit, with a warning; +// - pure "."/redundant-slash normalization -> silent. +func cleanArchivePath(raw string) (string, error) { + if path.IsAbs(raw) { + return "", fmt.Errorf("absolute path in archive: %q", raw) + } + + cleaned := path.Clean(raw) + + if cleaned == "." { + return "", fmt.Errorf("empty or invalid path in archive: %q", raw) + } + + if cleaned == ".." || strings.HasPrefix(cleaned, "../") { + return "", fmt.Errorf("path escapes archive root: %q", raw) + } + + // Stayed within root but contained a ".." segment -> warn (legal, unusual). + if containsDotDot(raw) { + log.Warn("path %q contains '..' segment", raw) + } + + return cleaned, nil +} + +// containsDotDot reports whether p has a ".." path segment, scanning by index +// to avoid the allocation of strings.Split. +func containsDotDot(p string) bool { + for len(p) > 0 { + i := strings.IndexByte(p, '/') + var seg string + if i < 0 { + seg, p = p, "" + } else { + seg, p = p[:i], p[i+1:] + } + if seg == ".." { + return true + } + } + return false +} diff --git a/internal/source/source_test.go b/internal/source/source_test.go new file mode 100644 index 0000000..933c67a --- /dev/null +++ b/internal/source/source_test.go @@ -0,0 +1,95 @@ +package source + +import ( + "bytes" + "io" + "testing" + + "tsne.dev/hopper/internal/log" +) + +func TestCleanArchivePath(t *testing.T) { + tests := []struct { + raw string + want string + wantErr bool + warn bool + }{ + {"a/b.js", "a/b.js", false, false}, + {"./a//b.js", "a/b.js", false, false}, + {"/abs/x", "", true, false}, + {"../escape", "", true, false}, + {"a/../../escape", "", true, false}, + {"a/../b.js", "b.js", false, true}, // contained .. but stays in root + } + for _, tt := range tests { + var warn bytes.Buffer + log.Init(io.Discard, &warn) + got, err := cleanArchivePath(tt.raw) + if tt.wantErr { + if err == nil { + t.Errorf("%q: expected error", tt.raw) + } + continue + } + if err != nil { + t.Errorf("%q: unexpected error %v", tt.raw, err) + continue + } + if got != tt.want { + t.Errorf("%q: got %q want %q", tt.raw, got, tt.want) + } + if tt.warn && warn.Len() == 0 { + t.Errorf("%q: expected warning", tt.raw) + } + if !tt.warn && warn.Len() != 0 { + t.Errorf("%q: unexpected warning %q", tt.raw, warn.String()) + } + } +} + +func TestSelectPath(t *testing.T) { + tests := []struct { + cleaned string + root string + want string + wantOK bool + }{ + {"assets/x.js", "", "assets/x.js", true}, + {"README.md", "", "README.md", true}, + {"app/assets/x.js", "app", "assets/x.js", true}, + {"README.md", "app", "", false}, + {"app", "app", "", false}, + {"appendix/x", "app", "", false}, + {"dist/public/index.html", "dist/public", "index.html", true}, + {"dist/private/x", "dist/public", "", false}, + } + for _, tt := range tests { + s := &TarGzSource{root: tt.root} + got, ok := s.selectPath(tt.cleaned) + if ok != tt.wantOK { + t.Errorf("selectPath(%q, %q): ok = %v, want %v", tt.cleaned, tt.root, ok, tt.wantOK) + continue + } + if ok && got != tt.want { + t.Errorf("selectPath(%q, %q) = %q, want %q", tt.cleaned, tt.root, got, tt.want) + } + } +} + +func TestContainsDotDot(t *testing.T) { + cases := map[string]bool{ + "a/b/c": false, + "..": true, + "../x": true, + "a/../b": true, + "a/..b/c": false, + "": false, + "a/b/..": true, + } + for in, want := range cases { + if got := containsDotDot(in); got != want { + t.Errorf("containsDotDot(%q) = %v, want %v", in, got, want) + } + } +} -- cgit v1.3