Skip to content

Commit 3a89624

Browse files
authored
Add Hash method to Reader for computing archive checksums (#9)
* Add Hash method to Reader for computing archive checksums from buffered raw bytes * Make OpenBytes zero-copy by dispatching directly on the caller's slice * Add OpenBytesWithPrefix for zero-copy prefix-stripped opens
1 parent 69b6888 commit 3a89624

12 files changed

Lines changed: 495 additions & 94 deletions

File tree

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,26 @@ func main() {
4646
}
4747
```
4848

49+
### Hashing
50+
51+
`Open` buffers the raw archive bytes in memory, so the reader can compute checksums of the original artifact without re-reading from the source. This is useful for verifying a downloaded package against the digest published by its registry.
52+
53+
```go
54+
reader, _ := archives.Open("rails-7.1.0.gem", f)
55+
defer reader.Close()
56+
57+
sha, _ := reader.Hash(archives.SHA256)
58+
fmt.Println(sha) // hex-encoded sha256 of the .gem file
59+
60+
// also available: archives.SHA512, archives.SHA1, archives.MD5
61+
```
62+
63+
The hash is computed over the archive as it was passed to `Open`, not the decompressed contents. For nested formats like gems this means the outer `.gem` file, which is what rubygems.org publishes. If you already have the bytes in hand, `OpenBytes` skips the extra read:
64+
65+
```go
66+
reader, _ := archives.OpenBytes("pkg.tgz", data)
67+
```
68+
4969
### Prefix stripping
5070

5171
Some package formats wrap content in a directory (npm uses `package/`). `OpenWithPrefix` strips a path prefix from all entries:

archives.go

Lines changed: 63 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,13 @@ import (
1919

2020
// FileInfo represents metadata about a file in an archive.
2121
type FileInfo struct {
22-
Path string // Full path within archive
23-
Name string // Base name
24-
Size int64 // Uncompressed size in bytes
25-
ModTime time.Time // Modification time
26-
IsDir bool // Whether this is a directory
27-
Mode uint32 // File mode/permissions
28-
CompressedSize int64 // Compressed size (if available)
22+
Path string // Full path within archive
23+
Name string // Base name
24+
Size int64 // Uncompressed size in bytes
25+
ModTime time.Time // Modification time
26+
IsDir bool // Whether this is a directory
27+
Mode uint32 // File mode/permissions
28+
CompressedSize int64 // Compressed size (if available)
2929
}
3030

3131
// Reader provides methods to browse and extract files from archives.
@@ -41,60 +41,100 @@ type Reader interface {
4141
// Returns io.ReadCloser for the file content.
4242
Extract(filePath string) (io.ReadCloser, error)
4343

44+
// Hash returns the hex-encoded digest of the raw archive bytes using
45+
// the named algorithm. Supported algorithms are SHA256, SHA512, SHA1
46+
// and MD5. The hash is computed over the original archive as passed
47+
// to Open, not the decompressed contents.
48+
Hash(algo string) (string, error)
49+
4450
// Close releases resources associated with the reader.
4551
Close() error
4652
}
4753

4854
// Open creates an archive reader for the given content.
4955
// The filename is used to detect the archive format.
5056
// The content reader will be read entirely into memory.
57+
//
5158
//nolint:ireturn // factory function returning interface by design
5259
func Open(filename string, content io.Reader) (Reader, error) {
5360
format := detectFormat(filename)
5461
if format == "" {
5562
return nil, fmt.Errorf("unsupported archive format: %s", filename)
5663
}
5764

58-
var reader Reader
59-
var err error
65+
raw, err := io.ReadAll(content)
66+
if err != nil {
67+
return nil, fmt.Errorf("reading archive content: %w", err)
68+
}
69+
70+
return openRaw(format, raw)
71+
}
72+
73+
// OpenBytes is like Open but accepts the archive content as a byte slice.
74+
// The slice is retained (not copied) for the lifetime of the Reader and
75+
// must not be modified by the caller after this call.
76+
//
77+
//nolint:ireturn // factory function returning interface by design
78+
func OpenBytes(filename string, content []byte) (Reader, error) {
79+
format := detectFormat(filename)
80+
if format == "" {
81+
return nil, fmt.Errorf("unsupported archive format: %s", filename)
82+
}
83+
84+
return openRaw(format, content)
85+
}
6086

87+
//nolint:ireturn
88+
func openRaw(format string, raw []byte) (Reader, error) {
6189
switch format {
6290
case "zip":
63-
reader, err = openZip(content)
91+
return openZip(raw)
6492
case "tar":
65-
reader, err = openTar(content, "")
93+
return openTar(raw, "")
6694
case "tar.gz", "tgz":
67-
reader, err = openTar(content, "gzip")
95+
return openTar(raw, "gzip")
6896
case "tar.bz2":
69-
reader, err = openTar(content, "bzip2")
97+
return openTar(raw, "bzip2")
7098
case "tar.xz":
71-
reader, err = openTar(content, "xz")
99+
return openTar(raw, "xz")
72100
case "gem":
73-
reader, err = openGem(content)
101+
return openGem(raw)
74102
default:
75103
return nil, fmt.Errorf("unsupported format: %s", format)
76104
}
77-
78-
return reader, err
79105
}
80106

81107
// OpenWithPrefix opens an archive and strips the given prefix from all paths.
82108
// This is useful for npm packages which wrap content in a "package/" directory.
109+
//
83110
//nolint:ireturn // factory function returning interface by design
84111
func OpenWithPrefix(filename string, content io.Reader, stripPrefix string) (Reader, error) {
85112
reader, err := Open(filename, content)
86113
if err != nil {
87114
return nil, err
88115
}
116+
return wrapPrefix(reader, stripPrefix), nil
117+
}
89118

90-
if stripPrefix == "" {
91-
return reader, nil
119+
// OpenBytesWithPrefix is like OpenWithPrefix but accepts the archive content as
120+
// a byte slice. The slice is retained (not copied) for the lifetime of the
121+
// Reader and must not be modified by the caller after this call.
122+
//
123+
//nolint:ireturn // factory function returning interface by design
124+
func OpenBytesWithPrefix(filename string, content []byte, stripPrefix string) (Reader, error) {
125+
reader, err := OpenBytes(filename, content)
126+
if err != nil {
127+
return nil, err
92128
}
129+
return wrapPrefix(reader, stripPrefix), nil
130+
}
93131

94-
return &prefixStripper{
95-
reader: reader,
96-
prefix: stripPrefix,
97-
}, nil
132+
//nolint:ireturn
133+
func wrapPrefix(reader Reader, stripPrefix string) Reader {
134+
if stripPrefix == "" {
135+
return reader
136+
}
137+
return &prefixStripper{reader: reader, prefix: stripPrefix}
98138
}
99139

100140
// detectFormat determines archive format from filename extension.

archives_test.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ func createTestZip() []byte {
140140

141141
func TestZipReader(t *testing.T) {
142142
data := createTestZip()
143-
reader, err := openZip(bytes.NewReader(data))
143+
reader, err := openZip(data)
144144
if err != nil {
145145
t.Fatalf("openZip failed: %v", err)
146146
}
@@ -216,7 +216,7 @@ func createTestTarGz() []byte {
216216

217217
func TestTarReader(t *testing.T) {
218218
data := createTestTarGz()
219-
reader, err := openTar(bytes.NewReader(data), "gzip")
219+
reader, err := openTar(data, "gzip")
220220
if err != nil {
221221
t.Fatalf("openTar failed: %v", err)
222222
}
@@ -279,7 +279,7 @@ func TestOpen(t *testing.T) {
279279

280280
func TestZipListDir(t *testing.T) {
281281
data := createTestZip()
282-
reader, err := openZip(bytes.NewReader(data))
282+
reader, err := openZip(data)
283283
if err != nil {
284284
t.Fatalf("openZip failed: %v", err)
285285
}
@@ -484,7 +484,7 @@ func assertNoDuplicates(t *testing.T, label string, files []FileInfo) {
484484

485485
func TestZipListDirNoDuplicatesWithExplicitDirEntries(t *testing.T) {
486486
data := createTestZipWithDirEntries()
487-
reader, err := openZip(bytes.NewReader(data))
487+
reader, err := openZip(data)
488488
if err != nil {
489489
t.Fatalf("openZip failed: %v", err)
490490
}
@@ -505,7 +505,7 @@ func TestZipListDirNoDuplicatesWithExplicitDirEntries(t *testing.T) {
505505

506506
func TestTarListDirNoDuplicatesWithExplicitDirEntries(t *testing.T) {
507507
data := createTestTarGzWithDirEntries()
508-
reader, err := openTar(bytes.NewReader(data), "gzip")
508+
reader, err := openTar(data, "gzip")
509509
if err != nil {
510510
t.Fatalf("openTar failed: %v", err)
511511
}
@@ -591,7 +591,7 @@ func TestOpenTarRejectsDecompressBomb(t *testing.T) {
591591
_ = tw.Close()
592592
_ = gw.Close()
593593

594-
_, err := openTar(bytes.NewReader(buf.Bytes()), "gzip")
594+
_, err := openTar(buf.Bytes(), "gzip")
595595
if err == nil {
596596
t.Fatal("expected error for oversized decompressed content")
597597
}
@@ -619,7 +619,7 @@ func TestOpenTarAcceptsWithinLimit(t *testing.T) {
619619
_ = tw.Close()
620620
_ = gw.Close()
621621

622-
reader, err := openTar(bytes.NewReader(buf.Bytes()), "gzip")
622+
reader, err := openTar(buf.Bytes(), "gzip")
623623
if err != nil {
624624
t.Fatalf("unexpected error: %v", err)
625625
}
@@ -654,7 +654,7 @@ func TestOpenTarRejectsCumulativeOverflow(t *testing.T) {
654654
_ = tw.Close()
655655
_ = gw.Close()
656656

657-
_, err := openTar(bytes.NewReader(buf.Bytes()), "gzip")
657+
_, err := openTar(buf.Bytes(), "gzip")
658658
if err == nil {
659659
t.Fatal("expected error when cumulative size exceeds limit")
660660
}
@@ -695,7 +695,7 @@ func TestOpenGemRejectsOversizedData(t *testing.T) {
695695
_, _ = outerTw.Write(dataTarGz)
696696
_ = outerTw.Close()
697697

698-
_, err := openGem(bytes.NewReader(gemBuf.Bytes()))
698+
_, err := openGem(gemBuf.Bytes())
699699
if err == nil {
700700
t.Fatal("expected error for oversized gem data")
701701
}

diff/diff.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,13 @@ const (
2525

2626
// FileDiff represents the diff for a single file.
2727
type FileDiff struct {
28-
Path string `json:"path"`
29-
Type string `json:"type"` // "modified", "added", "deleted", "renamed"
30-
OldPath string `json:"old_path,omitempty"`
31-
Diff string `json:"diff,omitempty"`
32-
IsBinary bool `json:"is_binary,omitempty"`
33-
LinesAdded int `json:"lines_added"`
34-
LinesDeleted int `json:"lines_deleted"`
28+
Path string `json:"path"`
29+
Type string `json:"type"` // "modified", "added", "deleted", "renamed"
30+
OldPath string `json:"old_path,omitempty"`
31+
Diff string `json:"diff,omitempty"`
32+
IsBinary bool `json:"is_binary,omitempty"`
33+
LinesAdded int `json:"lines_added"`
34+
LinesDeleted int `json:"lines_deleted"`
3535
}
3636

3737
// CompareResult contains the complete comparison between two versions.
@@ -231,9 +231,9 @@ func generateSimpleDiff(path string, oldContent, newContent []byte) (string, int
231231
oldEnd := len(oldLines) - 1
232232
newEnd := len(newLines) - 1
233233
for commonSuffix < maxCommon-commonPrefix &&
234-
oldEnd-commonSuffix >= commonPrefix &&
235-
newEnd-commonSuffix >= commonPrefix &&
236-
oldLines[oldEnd-commonSuffix] == newLines[newEnd-commonSuffix] {
234+
oldEnd-commonSuffix >= commonPrefix &&
235+
newEnd-commonSuffix >= commonPrefix &&
236+
oldLines[oldEnd-commonSuffix] == newLines[newEnd-commonSuffix] {
237237
commonSuffix++
238238
}
239239

diff/diff_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,15 @@ func createTestArchiveWithFiles(files map[string]string) []byte {
3333
func TestCompare(t *testing.T) {
3434
// Create two test archives
3535
oldFiles := map[string]string{
36-
"README.md": "# Old Version\n",
37-
"src/main.go": "package main\n\nfunc main() {\n\tprintln(\"old\")\n}\n",
38-
"deleted.txt": "this will be deleted",
36+
"README.md": "# Old Version\n",
37+
"src/main.go": "package main\n\nfunc main() {\n\tprintln(\"old\")\n}\n",
38+
"deleted.txt": "this will be deleted",
3939
}
4040

4141
newFiles := map[string]string{
42-
"README.md": "# New Version\n\nWith more content\n",
43-
"src/main.go": "package main\n\nfunc main() {\n\tprintln(\"new\")\n}\n",
44-
"added.txt": "this is new",
42+
"README.md": "# New Version\n\nWith more content\n",
43+
"src/main.go": "package main\n\nfunc main() {\n\tprintln(\"new\")\n}\n",
44+
"added.txt": "this is new",
4545
}
4646

4747
oldArchive, err := archives.Open("old.tar.gz", bytes.NewReader(createTestArchiveWithFiles(oldFiles)))

gem.go

Lines changed: 12 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package archives
22

33
import (
44
"archive/tar"
5+
"bytes"
56
"fmt"
67
"io"
78
)
@@ -10,12 +11,12 @@ import (
1011
// - gem file is a tar archive containing metadata.gz and data.tar.gz
1112
// - data.tar.gz contains the actual source code
1213
type gemReader struct {
13-
dataReader Reader // The inner data.tar.gz reader
14+
raw []byte
15+
dataReader Reader
1416
}
1517

16-
func openGem(content io.Reader) (*gemReader, error) {
17-
// Read the gem file as a tar archive
18-
tr := tar.NewReader(content)
18+
func openGem(raw []byte) (*gemReader, error) {
19+
tr := tar.NewReader(bytes.NewReader(raw))
1920

2021
// Find data.tar.gz in the gem
2122
for {
@@ -37,13 +38,12 @@ func openGem(content io.Reader) (*gemReader, error) {
3738
return nil, fmt.Errorf("%w: data.tar.gz exceeds %d bytes", ErrDecompressLimit, maxDecompressedSize)
3839
}
3940

40-
// Open the inner tar.gz
41-
dataReader, err := openTar(io.NopCloser(newBytesReader(dataContent)), "gzip")
41+
dataReader, err := openTar(dataContent, "gzip")
4242
if err != nil {
4343
return nil, fmt.Errorf("opening data.tar.gz: %w", err)
4444
}
4545

46-
return &gemReader{dataReader: dataReader}, nil
46+
return &gemReader{raw: raw, dataReader: dataReader}, nil
4747
}
4848
}
4949
}
@@ -60,32 +60,14 @@ func (g *gemReader) Extract(filePath string) (io.ReadCloser, error) {
6060
return g.dataReader.Extract(filePath)
6161
}
6262

63+
func (g *gemReader) Hash(algo string) (string, error) {
64+
return hashRaw(g.raw, algo)
65+
}
66+
6367
func (g *gemReader) Close() error {
68+
g.raw = nil
6469
if g.dataReader != nil {
6570
return g.dataReader.Close()
6671
}
6772
return nil
6873
}
69-
70-
// bytesReader implements io.Reader from a byte slice
71-
type bytesReader struct {
72-
data []byte
73-
pos int
74-
}
75-
76-
func newBytesReader(data []byte) *bytesReader {
77-
return &bytesReader{data: data}
78-
}
79-
80-
func (b *bytesReader) Read(p []byte) (n int, err error) {
81-
if b.pos >= len(b.data) {
82-
return 0, io.EOF
83-
}
84-
n = copy(p, b.data[b.pos:])
85-
b.pos += n
86-
return n, nil
87-
}
88-
89-
func (b *bytesReader) Close() error {
90-
return nil
91-
}

0 commit comments

Comments
 (0)