135 lines
3.4 KiB
Go
135 lines
3.4 KiB
Go
//go:build linux || freebsd
|
|
// +build linux freebsd
|
|
|
|
package archive
|
|
|
|
import (
|
|
"archive/tar"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type tarExtractor struct{ *BaseExtractor }
|
|
|
|
func NewTarExtractor() *tarExtractor {
|
|
return &tarExtractor{NewBaseExtractor("tar", []string{".tar"})}
|
|
}
|
|
|
|
// Extract extracts a tar archive into destDir (streaming-friendly)
|
|
func (t *tarExtractor) Extract(r io.Reader, destDir string) error {
|
|
tr := tar.NewReader(r)
|
|
|
|
for {
|
|
header, err := tr.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read tar header: %w", err)
|
|
}
|
|
|
|
if err := t.extractEntry(header, tr, destDir); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// extractEntry extracts a single entry from a tar archive
|
|
func (t *tarExtractor) extractEntry(header *tar.Header, r io.Reader, destDir string) error {
|
|
// Sanitize path to prevent tar-slip attacks
|
|
cleanName := filepath.Clean(header.Name)
|
|
if strings.HasPrefix(cleanName, "..") || filepath.IsAbs(cleanName) {
|
|
return fmt.Errorf("unsafe path in tar: %s", header.Name)
|
|
}
|
|
|
|
targetPath := filepath.Join(destDir, cleanName)
|
|
|
|
switch header.Typeflag {
|
|
case tar.TypeDir:
|
|
// Create directory with original permissions
|
|
return os.MkdirAll(targetPath, os.FileMode(header.Mode))
|
|
|
|
case tar.TypeReg:
|
|
// Create parent directories
|
|
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Create file with original permissions (mask setuid/setgid/sticky)
|
|
mode := os.FileMode(header.Mode) & 0777
|
|
file, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
|
|
// Copy contents
|
|
if _, err := io.Copy(file, r); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Restore timestamps
|
|
atime := header.AccessTime
|
|
if atime.IsZero() {
|
|
atime = time.Now()
|
|
}
|
|
mtime := header.ModTime
|
|
if mtime.IsZero() {
|
|
mtime = time.Now()
|
|
}
|
|
if err := os.Chtimes(targetPath, atime, mtime); err != nil {
|
|
// Non-fatal
|
|
}
|
|
|
|
return nil
|
|
|
|
case tar.TypeSymlink:
|
|
// Validate symlink target against path traversal
|
|
linkTarget := filepath.Clean(header.Linkname)
|
|
if filepath.IsAbs(linkTarget) || strings.HasPrefix(linkTarget, "..") {
|
|
return fmt.Errorf("symlink target outside extraction directory: %s", header.Linkname)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
|
|
return err
|
|
}
|
|
os.Remove(targetPath)
|
|
return os.Symlink(linkTarget, targetPath)
|
|
|
|
case tar.TypeLink:
|
|
// Hard link - validate target stays within destDir to prevent path traversal
|
|
cleanTarget := filepath.Clean(header.Linkname)
|
|
if filepath.IsAbs(cleanTarget) || strings.HasPrefix(cleanTarget, "..") {
|
|
return fmt.Errorf("hard link target outside extraction directory: %s", header.Linkname)
|
|
}
|
|
linkTarget := filepath.Join(destDir, cleanTarget)
|
|
// Ensure resolved path does not escape destDir
|
|
if !strings.HasPrefix(linkTarget, destDir+string(filepath.Separator)) && linkTarget != destDir {
|
|
return fmt.Errorf("hard link target outside extraction directory: %s", header.Linkname)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
|
|
return err
|
|
}
|
|
return os.Link(linkTarget, targetPath)
|
|
|
|
case tar.TypeChar, tar.TypeBlock:
|
|
// Device files - skip with warning (requires root)
|
|
return nil
|
|
|
|
case tar.TypeFifo:
|
|
// FIFO - skip (platform-specific)
|
|
return nil
|
|
|
|
default:
|
|
// Unknown type - skip
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func init() { Register(NewTarExtractor()) }
|