Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 128 additions & 13 deletions archive/archive.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,43 +6,153 @@ import (
"bytes"
"errors"
"io"
"log"
"math"
"strings"

"golang.org/x/xerrors"
)

// Ref:
// https://github.com/golang/go/blob/go1.24.0/src/archive/tar/format.go
// https://github.com/golang/go/blob/go1.24.0/src/archive/tar/writer.go
const (
tarBlockSize = 512
tarEndBlockBytes = 2 * tarBlockSize
)

// ErrArchiveTooLarge reports that archive expansion would exceed the
// configured limit.
var ErrArchiveTooLarge = xerrors.New("archive exceeds maximum size")

// ErrInvalidZipContent reports that a ZIP entry is malformed or its
// contents fail validation during conversion.
var ErrInvalidZipContent = xerrors.New("invalid zip content")

// CreateTarFromZip converts the given zipReader to a tar archive.
// maxSize limits the total tar output, including tar metadata.
func CreateTarFromZip(zipReader *zip.Reader, maxSize int64) ([]byte, error) {
err := validateZipArchiveSize(zipReader, maxSize)
if err != nil {
return nil, err
}

var tarBuffer bytes.Buffer
err := writeTarArchive(&tarBuffer, zipReader, maxSize)
err = writeTarArchive(&tarBuffer, zipReader, maxSize)
if err != nil {
return nil, err
}
return tarBuffer.Bytes(), nil
}

func writeTarArchive(w io.Writer, zipReader *zip.Reader, maxSize int64) error {
tarWriter := tar.NewWriter(w)
defer tarWriter.Close()
// validateZipArchiveSize performs a metadata-based preflight size
// check before conversion. The actual tar output limit will still be
// enforced while streaming.
func validateZipArchiveSize(zipReader *zip.Reader, maxSize int64) error {
if maxSize < 0 {
return ErrArchiveTooLarge
}

maxBytes := uint64(maxSize)
totalBytes := uint64(tarEndBlockBytes)
if totalBytes > maxBytes {
return ErrArchiveTooLarge
}

for _, file := range zipReader.File {
err := processFileInZipArchive(file, tarWriter, maxSize)
entrySize, err := projectedTarEntrySize(file)
if err != nil {
return err
}
if entrySize > maxBytes-totalBytes {
return ErrArchiveTooLarge
}
totalBytes += entrySize
}

return nil
}

func processFileInZipArchive(file *zip.File, tarWriter *tar.Writer, maxSize int64) error {
func projectedTarEntrySize(file *zip.File) (uint64, error) {
// Each tar entry contributes one header block plus its data
// rounded up to the next tar block boundary.
size := file.UncompressedSize64
if remainder := size % tarBlockSize; remainder != 0 {
padding := tarBlockSize - remainder
if size > math.MaxUint64-padding {
return 0, ErrArchiveTooLarge
}
size += padding
}

if size > math.MaxUint64-tarBlockSize {
return 0, ErrArchiveTooLarge
}

return tarBlockSize + size, nil
}

type limitedWriter struct {
w io.Writer
remaining int64
}

func (w *limitedWriter) Write(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
if w.remaining <= 0 {
return 0, ErrArchiveTooLarge
}

origLen := len(p)
if int64(origLen) > w.remaining {
p = p[:int(w.remaining)]
}

n, err := w.w.Write(p)
// io.Writer may report both written bytes and an error, so
// account for any accepted bytes before returning the error.
w.remaining -= int64(n)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

super micro nit: move this after the if err != nil check that returns the error below.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! This is intentional: the contract of io.Writer is that Write can return both an error and a valid n, so the wrapper mirrors that behavior by reflecting the best-effort write and then returning the error.

if err != nil {
return n, err
}
if n < origLen {
return n, ErrArchiveTooLarge
}
return n, nil
}

func writeTarArchive(w io.Writer, zipReader *zip.Reader, maxSize int64) error {
tarWriter := tar.NewWriter(&limitedWriter{
w: w,
remaining: maxSize,
})

for _, file := range zipReader.File {
err := processFileInZipArchive(file, tarWriter)
if err != nil {
return err
}
}

return tarWriter.Close()
}

func processFileInZipArchive(file *zip.File, tarWriter *tar.Writer) error {
fileReader, err := file.Open()
if err != nil {
return err
}
defer fileReader.Close()

size := file.FileInfo().Size()
if size < 0 {
return ErrArchiveTooLarge
}

err = tarWriter.WriteHeader(&tar.Header{
Name: file.Name,
Size: file.FileInfo().Size(),
Size: size,
Mode: int64(file.Mode()),
ModTime: file.Modified,
// Note: Zip archives do not store ownership information.
Expand All @@ -53,12 +163,17 @@ func processFileInZipArchive(file *zip.File, tarWriter *tar.Writer, maxSize int6
return err
}

n, err := io.CopyN(tarWriter, fileReader, maxSize)
log.Println(file.Name, n, err)
if errors.Is(err, io.EOF) {
err = nil
_, err = io.CopyN(tarWriter, fileReader, size)
switch {
case errors.Is(err, io.EOF), errors.Is(err, io.ErrUnexpectedEOF):
return ErrInvalidZipContent
case errors.Is(err, zip.ErrChecksum), errors.Is(err, zip.ErrFormat):
return ErrInvalidZipContent
case err != nil:
return err
default:
return nil
}
return err
}

// CreateZipFromTar converts the given tarReader to a zip archive.
Expand Down
99 changes: 96 additions & 3 deletions archive/archive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"archive/tar"
"archive/zip"
"bytes"
"encoding/binary"
"io/fs"
"os"
"os/exec"
Expand Down Expand Up @@ -35,21 +36,113 @@ func TestCreateTarFromZip(t *testing.T) {
zr, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes)))
require.NoError(t, err, "failed to parse sample zip file")

tarBytes, err := archive.CreateTarFromZip(zr, int64(len(zipBytes)))
wantTar := archivetest.TestTarFileBytes()
gotTar, err := archive.CreateTarFromZip(zr, int64(len(wantTar)))
require.NoError(t, err, "failed to convert zip to tar")

archivetest.AssertSampleTarFile(t, tarBytes)
archivetest.AssertSampleTarFile(t, gotTar)

tempDir := t.TempDir()
tempFilePath := filepath.Join(tempDir, "test.tar")
err = os.WriteFile(tempFilePath, tarBytes, 0o600)
err = os.WriteFile(tempFilePath, gotTar, 0o600)
require.NoError(t, err, "failed to write converted tar file")

cmd := exec.CommandContext(ctx, "tar", "--extract", "--verbose", "--file", tempFilePath, "--directory", tempDir)
require.NoError(t, cmd.Run(), "failed to extract converted tar file")
assertExtractedFiles(t, tempDir, true)
}

func buildTestZip(t *testing.T, files map[string]string) []byte {
t.Helper()

var zipBytes bytes.Buffer
zw := zip.NewWriter(&zipBytes)
for name, contents := range files {
w, err := zw.Create(name)
require.NoError(t, err)

_, err = w.Write([]byte(contents))
require.NoError(t, err)
}
require.NoError(t, zw.Close())

return zipBytes.Bytes()
}

func TestCreateTarFromZip_RejectsOversizedAggregateExpansion(t *testing.T) {
t.Parallel()

zipBytes := buildTestZip(t, map[string]string{
"a.txt": strings.Repeat("a", 600),
"b.txt": strings.Repeat("b", 600),
"c.txt": strings.Repeat("c", 600),
})

zr, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes)))
require.NoError(t, err)

tarBytes, err := archive.CreateTarFromZip(zr, 1024)
require.Error(t, err)
require.Nil(t, tarBytes)
}

func TestCreateTarFromZip_RejectsInvalidZipMetadata(t *testing.T) {
t.Parallel()

// Ref: https://github.com/golang/go/blob/go1.24.0/src/archive/zip/struct.go
corruptZipUncompressedSize := func(t *testing.T, zipBytes []byte, size uint32) []byte {
t.Helper()

const (
directoryHeaderSignature = "PK\x01\x02"
uncompressedSizeOffset = 24
)
hdrOffset := bytes.Index(zipBytes, []byte(directoryHeaderSignature))
require.NotEqual(t, -1, hdrOffset, "missing ZIP central directory header")
corrupted := bytes.Clone(zipBytes)
sizeBytes := corrupted[hdrOffset+uncompressedSizeOffset : hdrOffset+uncompressedSizeOffset+4]
binary.LittleEndian.PutUint32(sizeBytes, size)

return corrupted
}

zipBytes := buildTestZip(t, map[string]string{
"hello.txt": "hello",
})
zipBytes = corruptZipUncompressedSize(t, zipBytes, 6)

zr, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes)))
require.NoError(t, err)

// Keep the size limit large so this test exercises the invalid
// ZIP metadata path rather than the tar output limit.
maxSize := int64(4096)
tarBytes, err := archive.CreateTarFromZip(zr, maxSize)
require.ErrorIs(t, err, archive.ErrInvalidZipContent)
require.Nil(t, tarBytes)
}

func TestCreateTarFromZip_RejectsOversizedTarOverhead(t *testing.T) {
t.Parallel()

// Empty files keep the ZIP payload tiny while still forcing tar
// headers and end-of-archive blocks to consume output budget.
zipBytes := buildTestZip(t, map[string]string{
"empty-a.txt": "",
"empty-b.txt": "",
})

zr, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes)))
require.NoError(t, err)

// Two empty tar entries still need 2 header blocks plus the 2
// end-of-archive blocks, so the output is 2048 bytes and must
// exceed this limit.
tarBytes, err := archive.CreateTarFromZip(zr, 2047)
require.Error(t, err)
require.Nil(t, tarBytes)
}

func TestCreateZipFromTar(t *testing.T) {
t.Parallel()
if runtime.GOOS != "linux" {
Expand Down
23 changes: 18 additions & 5 deletions coderd/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,24 @@ func (api *API) postFile(rw http.ResponseWriter, r *http.Request) {

data, err = archive.CreateTarFromZip(zipReader, HTTPFileMaxBytes)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error processing .zip archive.",
Detail: err.Error(),
})
return
switch {
case errors.Is(err, archive.ErrArchiveTooLarge):
httpapi.Write(ctx, rw, http.StatusRequestEntityTooLarge, codersdk.Response{
Message: "Expanded .zip archive exceeds maximum size.",
})
return
case errors.Is(err, archive.ErrInvalidZipContent):
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid .zip archive contents.",
})
return
default:
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error processing .zip archive.",
Detail: err.Error(),
})
return
}
}
contentType = tarMimeType
}
Expand Down
Loading
Loading