mirror of
https://github.com/luxfi/cli.git
synced 2026-10-30 17:01:20 +00:00
266 lines
7.6 KiB
Go
266 lines
7.6 KiB
Go
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package publicarchive
|
|
|
|
import (
|
|
"archive/tar"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/luxfi/cli/v2/sdk/constants"
|
|
"github.com/luxfi/cli/v2/sdk/network"
|
|
luxdConstants "github.com/luxfi/node/v2/v2/utils/constants"
|
|
"github.com/luxfi/node/v2/v2/utils/logging"
|
|
"github.com/cavaliergopher/grab/v3"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
const (
|
|
updateInterval = 500 * time.Millisecond
|
|
maxFileSize = 10 * 1024 * 1024 * 1024 // 10GB per file
|
|
// public archive
|
|
PChainArchiveTestnet = "https://luxd-public-shared-database.lux-test.network/testnet/p-chain/luxd/data-tar/latest.tar"
|
|
)
|
|
|
|
type Getter struct {
|
|
client *grab.Client
|
|
request *grab.Request
|
|
size int64
|
|
bytesComplete int64
|
|
mutex *sync.RWMutex
|
|
}
|
|
|
|
type Downloader struct {
|
|
getter Getter
|
|
logger logging.Logger
|
|
currentOp *sync.Mutex
|
|
}
|
|
|
|
// newGetter returns a new Getter
|
|
func newGetter(endpoint string, target string) (Getter, error) {
|
|
if request, err := grab.NewRequest(target, endpoint); err != nil {
|
|
return Getter{}, err
|
|
} else {
|
|
return Getter{
|
|
client: grab.NewClient(),
|
|
request: request,
|
|
size: 0,
|
|
bytesComplete: 0,
|
|
mutex: &sync.RWMutex{},
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
// TODO: add timeout control based on contexts
|
|
// TODO: improve locks to provide graceful recovery under signals such as SIGTERM
|
|
|
|
// NewDownloader returns a new Downloader
|
|
// network: the network to download from ( testnet only).
|
|
// target: the path to download to
|
|
// logLevel: the log level
|
|
func NewDownloader(
|
|
network network.Network,
|
|
logger logging.Logger,
|
|
) (Downloader, error) {
|
|
tmpFile, err := os.CreateTemp("", "lux-cli-public-archive-*")
|
|
if err != nil {
|
|
return Downloader{}, err
|
|
}
|
|
|
|
switch network.ID {
|
|
case luxdConstants.TestnetID:
|
|
if getter, err := newGetter(PChainArchiveTestnet, tmpFile.Name()); err != nil {
|
|
return Downloader{}, err
|
|
} else {
|
|
return Downloader{
|
|
getter: getter,
|
|
logger: logger,
|
|
currentOp: &sync.Mutex{},
|
|
}, nil
|
|
}
|
|
default:
|
|
return Downloader{}, fmt.Errorf("unsupported network ID: %d. Testnet only supported", network.ID)
|
|
}
|
|
}
|
|
|
|
func (d Downloader) Download() error {
|
|
d.logger.Info("Download started from", zap.String("url", d.getter.request.URL().String()))
|
|
|
|
d.currentOp.Lock()
|
|
defer d.currentOp.Unlock()
|
|
|
|
resp := d.getter.client.Do(d.getter.request)
|
|
d.setDownloadSize(resp.Size())
|
|
d.logger.Debug("Download response received",
|
|
zap.String("status", resp.HTTPResponse.Status))
|
|
t := time.NewTicker(updateInterval)
|
|
defer t.Stop()
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
for {
|
|
select {
|
|
case <-t.C:
|
|
d.setBytesComplete(resp.BytesComplete())
|
|
d.logger.Info("Download progress",
|
|
zap.Int64("bytesComplete", d.GetBytesComplete()),
|
|
zap.Int64("size", d.GetDownloadSize()))
|
|
case <-resp.Done:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
<-resp.Done // Wait for the download to finish
|
|
t.Stop() // Stop the ticker
|
|
<-done
|
|
|
|
// check for errors
|
|
if err := resp.Err(); err != nil {
|
|
d.logger.Error("Download failed", zap.Error(err))
|
|
return err
|
|
}
|
|
|
|
d.logger.Info("Download saved to", zap.String("path", d.getter.request.Filename))
|
|
return nil
|
|
}
|
|
|
|
func (d Downloader) UnpackTo(targetDir string) error {
|
|
d.currentOp.Lock()
|
|
defer d.currentOp.Unlock()
|
|
// prepare destination path
|
|
if err := os.MkdirAll(targetDir, constants.UserOnlyWriteReadExecPerms); err != nil {
|
|
d.logger.Error("Failed to create target directory", zap.Error(err))
|
|
return err
|
|
}
|
|
tarFile, err := os.Open(d.getter.request.Filename)
|
|
if err != nil {
|
|
d.logger.Error("Failed to open tar file", zap.Error(err))
|
|
return fmt.Errorf("failed to open tar file: %w", err)
|
|
}
|
|
defer tarFile.Close()
|
|
|
|
tarReader := tar.NewReader(io.LimitReader(tarFile, maxFileSize))
|
|
extractedSize := int64(0)
|
|
for {
|
|
// codeql [security] suppressed reason: This usage is safe in this context.
|
|
header, err := tarReader.Next()
|
|
if err == io.EOF {
|
|
d.logger.Debug("End of archive reached")
|
|
break // End of archive
|
|
}
|
|
if err != nil {
|
|
d.logger.Error("Failed to read tar archive", zap.Error(err))
|
|
return fmt.Errorf("error reading tar archive: %w", err)
|
|
}
|
|
|
|
relPath, err := filepath.Rel(targetDir, filepath.Join(targetDir, filepath.Clean(header.Name)))
|
|
if err != nil || strings.HasPrefix(relPath, "..") {
|
|
d.logger.Error("Invalid file path", zap.String("path", header.Name))
|
|
return fmt.Errorf("invalid file path: %s", header.Name)
|
|
}
|
|
targetPath := filepath.Join(targetDir, relPath)
|
|
|
|
// security checks
|
|
if extractedSize+header.Size > maxFileSize {
|
|
d.logger.Error("File too large", zap.String("path", header.Name), zap.Int64("size", header.Size))
|
|
return fmt.Errorf("file too large: %s", header.Name)
|
|
}
|
|
if strings.Contains(header.Name, "..") {
|
|
d.logger.Error("Invalid file path", zap.String("path", header.Name))
|
|
return fmt.Errorf("invalid file path: %s", header.Name)
|
|
}
|
|
// end of security checks
|
|
|
|
switch header.Typeflag {
|
|
case tar.TypeDir:
|
|
d.logger.Debug("Creating directory", zap.String("path", targetPath))
|
|
if err := os.MkdirAll(targetPath, os.FileMode(header.Mode)); err != nil {
|
|
d.logger.Error("Failed to create directory", zap.Error(err))
|
|
return fmt.Errorf("failed to create directory: %w", err)
|
|
}
|
|
case tar.TypeReg:
|
|
d.logger.Debug("Ensure parent directory exists for ", zap.String("path", targetPath))
|
|
if err := os.MkdirAll(filepath.Dir(targetPath), os.FileMode(0o755)); err != nil {
|
|
d.logger.Error("Failed to create parent directory for file", zap.Error(err))
|
|
return fmt.Errorf("failed to create parent directory for file: %w", err)
|
|
}
|
|
d.logger.Debug("Creating file", zap.String("path", targetPath))
|
|
outFile, err := os.Create(targetPath)
|
|
if err != nil {
|
|
d.logger.Error("Failed to create file", zap.Error(err))
|
|
return fmt.Errorf("failed to create file: %w", err)
|
|
}
|
|
defer outFile.Close()
|
|
copied, err := io.CopyN(outFile, tarReader, header.Size)
|
|
if err != nil {
|
|
d.logger.Error("Failed to write file", zap.Error(err))
|
|
return fmt.Errorf("failed to write file: %w", err)
|
|
}
|
|
if copied < header.Size {
|
|
d.logger.Error("Incomplete file write", zap.String("path", targetPath))
|
|
return fmt.Errorf("incomplete file write for %s", targetPath)
|
|
}
|
|
extractedSize += header.Size
|
|
d.logger.Debug("Written bytes", zap.Int64("bytes", extractedSize))
|
|
default:
|
|
d.logger.Debug("Skipping file", zap.String("path", targetPath))
|
|
}
|
|
}
|
|
d.logger.Info("Download unpacked to", zap.String("path", targetDir))
|
|
return nil
|
|
}
|
|
|
|
// IsEmpty returns true if the Downloader is empty and not initialized
|
|
func (d Downloader) IsEmpty() bool {
|
|
return d.getter.client == nil
|
|
}
|
|
|
|
// IsComplete returns true if the download is complete
|
|
func (d Downloader) IsComplete() bool {
|
|
return d.GetBytesComplete() == d.GetDownloadSize()
|
|
}
|
|
|
|
func (d Downloader) GetFilePath() (string, error) {
|
|
if !d.IsComplete() {
|
|
return "", fmt.Errorf("download is not completed")
|
|
}
|
|
return d.getter.request.Filename, nil
|
|
}
|
|
|
|
// GetDownloadSize returns the size of the download
|
|
func (d Downloader) GetDownloadSize() int64 {
|
|
d.getter.mutex.RLock()
|
|
defer d.getter.mutex.RUnlock()
|
|
return d.getter.size
|
|
}
|
|
|
|
func (d Downloader) setDownloadSize(size int64) {
|
|
d.getter.mutex.Lock()
|
|
defer d.getter.mutex.Unlock()
|
|
d.getter.size = size
|
|
}
|
|
|
|
// GetCurrentProgress returns the current download progress
|
|
func (d Downloader) GetBytesComplete() int64 {
|
|
d.getter.mutex.RLock()
|
|
defer d.getter.mutex.RUnlock()
|
|
return d.getter.bytesComplete
|
|
}
|
|
|
|
func (d Downloader) setBytesComplete(progress int64) {
|
|
d.getter.mutex.Lock()
|
|
defer d.getter.mutex.Unlock()
|
|
d.getter.bytesComplete = progress
|
|
}
|
|
|
|
func (d Downloader) CleanUp() {
|
|
_ = os.Remove(d.getter.request.Filename)
|
|
}
|