aboutsummaryrefslogtreecommitdiff
path: root/internal/source
diff options
context:
space:
mode:
authortsne <tsne.dev@outlook.com>2026-07-06 16:52:20 +0200
committertsne <tsne.dev@outlook.com>2026-07-06 21:37:42 +0200
commit84da43998f7cc42cc72652333ac6e3d293abb23e (patch)
tree6610f82df854ae86f7f34152bb05fc712ef6f314 /internal/source
downloadhopper-84da43998f7cc42cc72652333ac6e3d293abb23e.tar.gz
initialHEADmain
Diffstat (limited to 'internal/source')
-rw-r--r--internal/source/source.go213
-rw-r--r--internal/source/source_test.go95
2 files changed, 308 insertions, 0 deletions
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)
+ }
+ }
+}