1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
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
}
|