// 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 }