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