resp→response, db→database pattern across mining, logging packages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
639 lines
18 KiB
Go
639 lines
18 KiB
Go
package mining
|
|
|
|
import (
|
|
"archive/tar"
|
|
"archive/zip"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"forge.lthn.ai/Snider/Mining/pkg/logging"
|
|
"github.com/adrg/xdg"
|
|
)
|
|
|
|
// buffer := NewLogBuffer(500)
|
|
// cmd.Stdout = buffer // satisfies io.Writer; ring-buffers miner output
|
|
type LogBuffer struct {
|
|
lines []string
|
|
maxLines int
|
|
mutex sync.RWMutex
|
|
}
|
|
|
|
// buffer := NewLogBuffer(500)
|
|
// cmd.Stdout = buffer
|
|
func NewLogBuffer(maxLines int) *LogBuffer {
|
|
return &LogBuffer{
|
|
lines: make([]string, 0, maxLines),
|
|
maxLines: maxLines,
|
|
}
|
|
}
|
|
|
|
// if len(line) > maxLineLength { line = line[:maxLineLength] + "... [truncated]" }
|
|
const maxLineLength = 2000
|
|
|
|
// cmd.Stdout = lb // satisfies io.Writer; timestamps and ring-buffers each line
|
|
func (logBuffer *LogBuffer) Write(p []byte) (n int, err error) {
|
|
logBuffer.mutex.Lock()
|
|
defer logBuffer.mutex.Unlock()
|
|
|
|
// Split input into lines
|
|
text := string(p)
|
|
newLines := strings.Split(text, "\n")
|
|
|
|
for _, line := range newLines {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
// Truncate excessively long lines to prevent memory bloat
|
|
if len(line) > maxLineLength {
|
|
line = line[:maxLineLength] + "... [truncated]"
|
|
}
|
|
// Add timestamp prefix
|
|
timestampedLine := "[" + time.Now().Format("15:04:05") + "] " + line
|
|
logBuffer.lines = append(logBuffer.lines, timestampedLine)
|
|
|
|
// Trim if over max - force reallocation to release memory
|
|
if len(logBuffer.lines) > logBuffer.maxLines {
|
|
newSlice := make([]string, logBuffer.maxLines)
|
|
copy(newSlice, logBuffer.lines[len(logBuffer.lines)-logBuffer.maxLines:])
|
|
logBuffer.lines = newSlice
|
|
}
|
|
}
|
|
return len(p), nil
|
|
}
|
|
|
|
// lines := logBuffer.GetLines()
|
|
// response.Logs = lines[max(0, len(lines)-100):]
|
|
func (logBuffer *LogBuffer) GetLines() []string {
|
|
logBuffer.mutex.RLock()
|
|
defer logBuffer.mutex.RUnlock()
|
|
result := make([]string, len(logBuffer.lines))
|
|
copy(result, logBuffer.lines)
|
|
return result
|
|
}
|
|
|
|
// logBuffer.Clear() // called on miner Stop() to release memory
|
|
func (logBuffer *LogBuffer) Clear() {
|
|
logBuffer.mutex.Lock()
|
|
defer logBuffer.mutex.Unlock()
|
|
logBuffer.lines = logBuffer.lines[:0]
|
|
}
|
|
|
|
// type XMRigMiner struct { BaseMiner }
|
|
// func NewXMRigMiner() *XMRigMiner { return &XMRigMiner{BaseMiner: BaseMiner{MinerType: "xmrig"}} }
|
|
type BaseMiner struct {
|
|
Name string `json:"name"`
|
|
MinerType string `json:"miner_type"` // Type identifier (e.g., "xmrig", "tt-miner")
|
|
Version string `json:"version"`
|
|
URL string `json:"url"`
|
|
Path string `json:"path"`
|
|
MinerBinary string `json:"miner_binary"`
|
|
ExecutableName string `json:"-"`
|
|
Running bool `json:"running"`
|
|
ConfigPath string `json:"configPath"`
|
|
API *API `json:"api"`
|
|
mutex sync.RWMutex
|
|
cmd *exec.Cmd
|
|
stdinPipe io.WriteCloser `json:"-"`
|
|
HashrateHistory []HashratePoint `json:"hashrateHistory"`
|
|
LowResHashrateHistory []HashratePoint `json:"lowResHashrateHistory"`
|
|
LastLowResAggregation time.Time `json:"-"`
|
|
LogBuffer *LogBuffer `json:"-"`
|
|
}
|
|
|
|
// minerType := miner.GetType() // "xmrig" or "tt-miner"
|
|
func (b *BaseMiner) GetType() string {
|
|
return b.MinerType
|
|
}
|
|
|
|
// name := miner.GetName() // e.g. "xmrig-randomx" or "tt-miner-kawpow"
|
|
func (b *BaseMiner) GetName() string {
|
|
b.mutex.RLock()
|
|
defer b.mutex.RUnlock()
|
|
return b.Name
|
|
}
|
|
|
|
// path := miner.GetPath() // e.g. "/home/user/.local/share/lethean-desktop/miners/xmrig"
|
|
func (b *BaseMiner) GetPath() string {
|
|
dataPath, err := xdg.DataFile("lethean-desktop/miners/" + b.ExecutableName)
|
|
if err != nil {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return filepath.Join(home, ".lethean-desktop", "miners", b.ExecutableName)
|
|
}
|
|
return dataPath
|
|
}
|
|
|
|
// binary := miner.GetBinaryPath() // e.g. "/home/user/.local/share/lethean-desktop/miners/xmrig/xmrig"
|
|
func (b *BaseMiner) GetBinaryPath() string {
|
|
b.mutex.RLock()
|
|
defer b.mutex.RUnlock()
|
|
return b.MinerBinary
|
|
}
|
|
|
|
// if err := miner.Stop(); err != nil { log.Warn("stop failed", ...) }
|
|
func (b *BaseMiner) Stop() error {
|
|
b.mutex.Lock()
|
|
|
|
if !b.Running || b.cmd == nil {
|
|
b.mutex.Unlock()
|
|
return ErrMinerNotRunning(b.Name)
|
|
}
|
|
|
|
// Close stdin pipe if open
|
|
if b.stdinPipe != nil {
|
|
b.stdinPipe.Close()
|
|
b.stdinPipe = nil
|
|
}
|
|
|
|
// Capture cmd locally to avoid race with Wait() goroutine
|
|
cmd := b.cmd
|
|
process := cmd.Process
|
|
|
|
// Mark as not running immediately to prevent concurrent Stop() calls
|
|
b.Running = false
|
|
b.cmd = nil
|
|
b.mutex.Unlock()
|
|
|
|
// Try graceful shutdown with SIGTERM first (Unix only)
|
|
if runtime.GOOS != "windows" {
|
|
if err := process.Signal(syscall.SIGTERM); err == nil {
|
|
// Wait up to 3 seconds for graceful shutdown
|
|
done := make(chan struct{})
|
|
go func() {
|
|
process.Wait()
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
return nil
|
|
case <-time.After(3 * time.Second):
|
|
// Process didn't exit gracefully, force kill below
|
|
}
|
|
}
|
|
}
|
|
|
|
// Force kill and wait for process to exit
|
|
if err := process.Kill(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Wait for process to fully terminate to avoid zombies
|
|
process.Wait()
|
|
return nil
|
|
}
|
|
|
|
// case <-time.After(stdinWriteTimeout): return ErrTimeout("stdin write")
|
|
const stdinWriteTimeout = 5 * time.Second
|
|
|
|
// if err := miner.WriteStdin("h"); err != nil { /* miner not running */ }
|
|
func (b *BaseMiner) WriteStdin(input string) error {
|
|
b.mutex.RLock()
|
|
stdinPipe := b.stdinPipe
|
|
running := b.Running
|
|
b.mutex.RUnlock()
|
|
|
|
if !running || stdinPipe == nil {
|
|
return ErrMinerNotRunning(b.Name)
|
|
}
|
|
|
|
// Append newline if not present
|
|
if !strings.HasSuffix(input, "\n") {
|
|
input += "\n"
|
|
}
|
|
|
|
// Write with timeout to prevent blocking indefinitely.
|
|
// Use buffered channel size 1 so goroutine can exit even if we don't read the result.
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
_, err := stdinPipe.Write([]byte(input))
|
|
// Non-blocking send - if timeout already fired, this won't block
|
|
select {
|
|
case done <- err:
|
|
default:
|
|
// Timeout already occurred, goroutine exits cleanly
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case err := <-done:
|
|
return err
|
|
case <-time.After(stdinWriteTimeout):
|
|
return ErrTimeout("stdin write: miner may be unresponsive")
|
|
}
|
|
}
|
|
|
|
// if err := miner.Uninstall(); err != nil { return err }
|
|
func (b *BaseMiner) Uninstall() error {
|
|
return os.RemoveAll(b.GetPath())
|
|
}
|
|
|
|
// if err := b.InstallFromURL("https://github.com/xmrig/xmrig/releases/download/v6.22.1/xmrig-6.22.1-linux-static-x64.tar.gz"); err != nil { return err }
|
|
func (b *BaseMiner) InstallFromURL(url string) error {
|
|
tmpfile, err := os.CreateTemp("", b.ExecutableName+"-")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.Remove(tmpfile.Name())
|
|
defer tmpfile.Close()
|
|
|
|
response, err := getHTTPClient().Get(url)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer response.Body.Close()
|
|
|
|
if response.StatusCode != http.StatusOK {
|
|
_, _ = io.Copy(io.Discard, response.Body) // Drain body to allow connection reuse (error ignored intentionally)
|
|
return ErrInstallFailed(b.ExecutableName).WithDetails("unexpected status code " + strconv.Itoa(response.StatusCode))
|
|
}
|
|
|
|
if _, err := io.Copy(tmpfile, response.Body); err != nil {
|
|
// Drain remaining body to allow connection reuse (error ignored intentionally)
|
|
_, _ = io.Copy(io.Discard, response.Body)
|
|
return err
|
|
}
|
|
|
|
baseInstallPath := b.GetPath()
|
|
if err := os.MkdirAll(baseInstallPath, 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
if strings.HasSuffix(url, ".zip") {
|
|
err = b.unzip(tmpfile.Name(), baseInstallPath)
|
|
} else {
|
|
err = b.untar(tmpfile.Name(), baseInstallPath)
|
|
}
|
|
if err != nil {
|
|
return ErrInstallFailed(b.ExecutableName).WithCause(err).WithDetails("failed to extract archive")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// current := parseVersion("6.24.0") // [6, 24, 0]
|
|
// previous := parseVersion("5.0.1") // [5, 0, 1]
|
|
// if compareVersions(current, previous) > 0 { /* current is newer */ }
|
|
func parseVersion(versionString string) []int {
|
|
parts := strings.Split(versionString, ".")
|
|
intParts := make([]int, len(parts))
|
|
for i, part := range parts {
|
|
val, err := strconv.Atoi(part)
|
|
if err != nil {
|
|
return []int{0} // Malformed version, treat as very old
|
|
}
|
|
intParts[i] = val
|
|
}
|
|
return intParts
|
|
}
|
|
|
|
// if compareVersions(parseVersion("6.24.0"), parseVersion("5.0.1")) > 0 { /* installed is newer, skip update */ }
|
|
func compareVersions(v1, v2 []int) int {
|
|
minimumLength := len(v1)
|
|
if len(v2) < minimumLength {
|
|
minimumLength = len(v2)
|
|
}
|
|
|
|
for i := 0; i < minimumLength; i++ {
|
|
if v1[i] > v2[i] {
|
|
return 1
|
|
}
|
|
if v1[i] < v2[i] {
|
|
return -1
|
|
}
|
|
}
|
|
|
|
if len(v1) > len(v2) {
|
|
return 1
|
|
}
|
|
if len(v1) < len(v2) {
|
|
return -1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// path, err := b.findMinerBinary()
|
|
// // path == "/home/user/.local/share/lethean-desktop/miners/xmrig/xmrig-6.24.0/xmrig"
|
|
func (b *BaseMiner) findMinerBinary() (string, error) {
|
|
executableName := b.ExecutableName
|
|
if runtime.GOOS == "windows" {
|
|
executableName += ".exe"
|
|
}
|
|
|
|
baseInstallPath := b.GetPath()
|
|
searchedPaths := []string{}
|
|
|
|
var highestVersion []int
|
|
var highestVersionDir string
|
|
|
|
// 1. Check the standard installation directory first
|
|
if _, err := os.Stat(baseInstallPath); err == nil {
|
|
dirs, err := os.ReadDir(baseInstallPath)
|
|
if err == nil {
|
|
for _, entry := range dirs {
|
|
if entry.IsDir() && strings.HasPrefix(entry.Name(), b.ExecutableName+"-") {
|
|
// Extract version string, e.g., "xmrig-6.24.0" -> "6.24.0"
|
|
versionStr := strings.TrimPrefix(entry.Name(), b.ExecutableName+"-")
|
|
currentVersion := parseVersion(versionStr)
|
|
|
|
if highestVersionDir == "" || compareVersions(currentVersion, highestVersion) > 0 {
|
|
highestVersion = currentVersion
|
|
highestVersionDir = entry.Name()
|
|
}
|
|
versionedPath := filepath.Join(baseInstallPath, entry.Name())
|
|
fullPath := filepath.Join(versionedPath, executableName)
|
|
searchedPaths = append(searchedPaths, fullPath)
|
|
}
|
|
}
|
|
}
|
|
|
|
if highestVersionDir != "" {
|
|
fullPath := filepath.Join(baseInstallPath, highestVersionDir, executableName)
|
|
if _, err := os.Stat(fullPath); err == nil {
|
|
logging.Debug("found miner binary at highest versioned path", logging.Fields{"path": fullPath})
|
|
return fullPath, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Fallback to searching the system PATH
|
|
path, err := exec.LookPath(executableName)
|
|
if err == nil {
|
|
absPath, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return "", ErrInternal("failed to get absolute path for '" + path + "'").WithCause(err)
|
|
}
|
|
logging.Debug("found miner binary in system PATH", logging.Fields{"path": absPath})
|
|
return absPath, nil
|
|
}
|
|
|
|
// If not found, return a detailed error
|
|
return "", ErrMinerNotFound(executableName).WithDetails("searched in: " + strings.Join(searchedPaths, ", ") + " and system PATH")
|
|
}
|
|
|
|
// details, err := miner.CheckInstallation()
|
|
// if !details.IsInstalled { logging.Warn("xmrig not found", logging.Fields{"error": err}) }
|
|
func (b *BaseMiner) CheckInstallation() (*InstallationDetails, error) {
|
|
binaryPath, err := b.findMinerBinary()
|
|
if err != nil {
|
|
return &InstallationDetails{IsInstalled: false}, err
|
|
}
|
|
|
|
b.MinerBinary = binaryPath
|
|
b.Path = filepath.Dir(binaryPath)
|
|
|
|
cmd := exec.Command(binaryPath, "--version")
|
|
var output bytes.Buffer
|
|
cmd.Stdout = &output
|
|
if err := cmd.Run(); err != nil {
|
|
b.Version = "Unknown (could not run executable)"
|
|
} else {
|
|
fields := strings.Fields(output.String())
|
|
if len(fields) >= 2 {
|
|
b.Version = fields[1]
|
|
} else {
|
|
b.Version = "Unknown (could not parse version)"
|
|
}
|
|
}
|
|
|
|
return &InstallationDetails{
|
|
IsInstalled: true,
|
|
MinerBinary: b.MinerBinary,
|
|
Path: b.Path,
|
|
Version: b.Version,
|
|
}, nil
|
|
}
|
|
|
|
// points := miner.GetHashrateHistory() // low-res (24h) + high-res (5min) points in chronological order
|
|
func (b *BaseMiner) GetHashrateHistory() []HashratePoint {
|
|
b.mutex.RLock()
|
|
defer b.mutex.RUnlock()
|
|
combinedHistory := make([]HashratePoint, 0, len(b.LowResHashrateHistory)+len(b.HashrateHistory))
|
|
combinedHistory = append(combinedHistory, b.LowResHashrateHistory...)
|
|
combinedHistory = append(combinedHistory, b.HashrateHistory...)
|
|
return combinedHistory
|
|
}
|
|
|
|
// miner.AddHashratePoint(HashratePoint{Timestamp: time.Now(), Hashrate: 1234.5})
|
|
func (b *BaseMiner) AddHashratePoint(point HashratePoint) {
|
|
b.mutex.Lock()
|
|
defer b.mutex.Unlock()
|
|
b.HashrateHistory = append(b.HashrateHistory, point)
|
|
}
|
|
|
|
// count := miner.GetHighResHistoryLength() // 0..30 points (last 5 min at 10s resolution)
|
|
func (b *BaseMiner) GetHighResHistoryLength() int {
|
|
b.mutex.RLock()
|
|
defer b.mutex.RUnlock()
|
|
return len(b.HashrateHistory)
|
|
}
|
|
|
|
// count := miner.GetLowResHistoryLength() // 0..1440 points (last 24h at 1min resolution)
|
|
func (b *BaseMiner) GetLowResHistoryLength() int {
|
|
b.mutex.RLock()
|
|
defer b.mutex.RUnlock()
|
|
return len(b.LowResHashrateHistory)
|
|
}
|
|
|
|
// lines := miner.GetLogs()
|
|
// response.Logs = lines[max(0, len(lines)-100):]
|
|
func (b *BaseMiner) GetLogs() []string {
|
|
b.mutex.RLock()
|
|
logBuffer := b.LogBuffer
|
|
b.mutex.RUnlock()
|
|
|
|
if logBuffer == nil {
|
|
return []string{}
|
|
}
|
|
return logBuffer.GetLines()
|
|
}
|
|
|
|
// miner.ReduceHashrateHistory(time.Now()) // aggregates high-res points older than 5 min into 1-min low-res buckets; trims low-res to 24h
|
|
func (b *BaseMiner) ReduceHashrateHistory(now time.Time) {
|
|
b.mutex.Lock()
|
|
defer b.mutex.Unlock()
|
|
|
|
if !b.LastLowResAggregation.IsZero() && now.Sub(b.LastLowResAggregation) < LowResolutionInterval {
|
|
return
|
|
}
|
|
|
|
var pointsToAggregate []HashratePoint
|
|
var newHighResHistory []HashratePoint
|
|
cutoff := now.Add(-HighResolutionDuration)
|
|
|
|
for _, point := range b.HashrateHistory {
|
|
if point.Timestamp.Before(cutoff) {
|
|
pointsToAggregate = append(pointsToAggregate, point)
|
|
} else {
|
|
newHighResHistory = append(newHighResHistory, point)
|
|
}
|
|
}
|
|
// Force reallocation if significantly oversized to free memory
|
|
if cap(b.HashrateHistory) > 1000 && len(newHighResHistory) < cap(b.HashrateHistory)/2 {
|
|
trimmed := make([]HashratePoint, len(newHighResHistory))
|
|
copy(trimmed, newHighResHistory)
|
|
b.HashrateHistory = trimmed
|
|
} else {
|
|
b.HashrateHistory = newHighResHistory
|
|
}
|
|
|
|
if len(pointsToAggregate) == 0 {
|
|
b.LastLowResAggregation = now
|
|
return
|
|
}
|
|
|
|
minuteGroups := make(map[time.Time][]int)
|
|
for _, point := range pointsToAggregate {
|
|
minute := point.Timestamp.Truncate(LowResolutionInterval)
|
|
minuteGroups[minute] = append(minuteGroups[minute], point.Hashrate)
|
|
}
|
|
|
|
var newLowResPoints []HashratePoint
|
|
for minute, hashrates := range minuteGroups {
|
|
if len(hashrates) > 0 {
|
|
totalHashrate := 0
|
|
for _, rate := range hashrates {
|
|
totalHashrate += rate
|
|
}
|
|
avgHashrate := totalHashrate / len(hashrates)
|
|
newLowResPoints = append(newLowResPoints, HashratePoint{Timestamp: minute, Hashrate: avgHashrate})
|
|
}
|
|
}
|
|
|
|
sort.Slice(newLowResPoints, func(i, j int) bool {
|
|
return newLowResPoints[i].Timestamp.Before(newLowResPoints[j].Timestamp)
|
|
})
|
|
|
|
b.LowResHashrateHistory = append(b.LowResHashrateHistory, newLowResPoints...)
|
|
|
|
lowResCutoff := now.Add(-LowResHistoryRetention)
|
|
firstValidLowResIndex := 0
|
|
for i, point := range b.LowResHashrateHistory {
|
|
if point.Timestamp.After(lowResCutoff) || point.Timestamp.Equal(lowResCutoff) {
|
|
firstValidLowResIndex = i
|
|
break
|
|
}
|
|
if i == len(b.LowResHashrateHistory)-1 {
|
|
firstValidLowResIndex = len(b.LowResHashrateHistory)
|
|
}
|
|
}
|
|
|
|
// Force reallocation if significantly oversized to free memory
|
|
newLowResLen := len(b.LowResHashrateHistory) - firstValidLowResIndex
|
|
if cap(b.LowResHashrateHistory) > 1000 && newLowResLen < cap(b.LowResHashrateHistory)/2 {
|
|
trimmed := make([]HashratePoint, newLowResLen)
|
|
copy(trimmed, b.LowResHashrateHistory[firstValidLowResIndex:])
|
|
b.LowResHashrateHistory = trimmed
|
|
} else {
|
|
b.LowResHashrateHistory = b.LowResHashrateHistory[firstValidLowResIndex:]
|
|
}
|
|
b.LastLowResAggregation = now
|
|
}
|
|
|
|
// b.unzip(tmpfile.Name(), "/home/user/.local/share/lethean-desktop/miners/xmrig")
|
|
func (b *BaseMiner) unzip(src, dest string) error {
|
|
zipReader, err := zip.OpenReader(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer zipReader.Close()
|
|
|
|
for _, zipEntry := range zipReader.File {
|
|
entryPath := filepath.Join(dest, zipEntry.Name)
|
|
if !strings.HasPrefix(entryPath, filepath.Clean(dest)+string(os.PathSeparator)) {
|
|
return ErrInternal("illegal file path in archive").WithDetails(entryPath)
|
|
}
|
|
if zipEntry.FileInfo().IsDir() {
|
|
if err := os.MkdirAll(entryPath, os.ModePerm); err != nil {
|
|
return ErrInternal("failed to create directory").WithCause(err).WithDetails(entryPath)
|
|
}
|
|
continue
|
|
}
|
|
|
|
if err = os.MkdirAll(filepath.Dir(entryPath), os.ModePerm); err != nil {
|
|
return err
|
|
}
|
|
outFile, err := os.OpenFile(entryPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, zipEntry.Mode())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
entryReader, err := zipEntry.Open()
|
|
if err != nil {
|
|
outFile.Close()
|
|
return err
|
|
}
|
|
_, err = io.Copy(outFile, entryReader)
|
|
outFile.Close()
|
|
entryReader.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// b.untar(tmpfile.Name(), "/home/user/.local/share/lethean-desktop/miners/xmrig")
|
|
func (b *BaseMiner) untar(src, dest string) error {
|
|
file, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
|
|
gzipReader, err := gzip.NewReader(file)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer gzipReader.Close()
|
|
|
|
tarReader := tar.NewReader(gzipReader)
|
|
|
|
for {
|
|
header, err := tarReader.Next()
|
|
if err == io.EOF {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
target := filepath.Join(dest, header.Name)
|
|
if !strings.HasPrefix(target, filepath.Clean(dest)+string(os.PathSeparator)) {
|
|
return ErrInternal("illegal file path in archive").WithDetails(header.Name)
|
|
}
|
|
|
|
switch header.Typeflag {
|
|
case tar.TypeDir:
|
|
if err := os.MkdirAll(target, 0755); err != nil {
|
|
return err
|
|
}
|
|
case tar.TypeReg:
|
|
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
|
return err
|
|
}
|
|
outputFile, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR|os.O_TRUNC, os.FileMode(header.Mode))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := io.Copy(outputFile, tarReader); err != nil {
|
|
outputFile.Close()
|
|
return err
|
|
}
|
|
outputFile.Close()
|
|
}
|
|
}
|
|
}
|