aboutsummaryrefslogtreecommitdiff
path: root/internal/source/source.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/source/source.go')
-rw-r--r--internal/source/source.go213
1 files changed, 213 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
+}