diff options
Diffstat (limited to 'internal/source/source.go')
| -rw-r--r-- | internal/source/source.go | 213 |
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 +} |