first commit
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
// This file fetches a pi package's contents from npm (#156). Rather than
|
||||
// implement an npm registry client, pigo shells out to the user's installed
|
||||
// `npm` — specifically `npm pack`, which downloads a package as a .tgz tarball
|
||||
// without running install scripts. pigo then extracts that tarball into a
|
||||
// temporary directory for the classify/distribute steps that follow.
|
||||
//
|
||||
// The fetch is deliberately side-effect-light: `npm pack` neither installs
|
||||
// dependencies nor runs lifecycle scripts, so downloading a package cannot
|
||||
// execute its code. Running the extracted extension is a separate, later step.
|
||||
//
|
||||
// npm packs every package into a top-level "package/" directory inside the
|
||||
// tarball; Fetch returns the path to that extracted directory.
|
||||
package pkgmgr
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FetchResult describes a package fetched to a temporary directory.
|
||||
type FetchResult struct {
|
||||
// Dir is the extracted package directory (the tarball's "package/" root).
|
||||
Dir string
|
||||
// TempRoot is the temporary directory holding Dir and the tarball; the
|
||||
// caller must Cleanup it when done.
|
||||
TempRoot string
|
||||
}
|
||||
|
||||
// Cleanup removes the temporary directory tree created by Fetch. Safe to call
|
||||
// on a zero FetchResult (no-op).
|
||||
func (r FetchResult) Cleanup() error {
|
||||
if r.TempRoot == "" {
|
||||
return nil
|
||||
}
|
||||
return os.RemoveAll(r.TempRoot)
|
||||
}
|
||||
|
||||
// npmExecutable is the npm binary name; a variable so tests can stub it.
|
||||
var npmExecutable = "npm"
|
||||
|
||||
// EnsureNPM reports an actionable error when npm is not on PATH. The install
|
||||
// command calls this before doing any work so it fails fast with guidance
|
||||
// rather than deep inside a fetch.
|
||||
func EnsureNPM() error {
|
||||
if _, err := exec.LookPath(npmExecutable); err != nil {
|
||||
return fmt.Errorf("npm not found; install Node.js/npm to use pigo install")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fetch downloads the package named by ref using `npm pack` and extracts it into
|
||||
// a fresh temporary directory. On success the caller owns the returned
|
||||
// FetchResult and must call Cleanup. On any failure the temporary directory is
|
||||
// removed before returning, so a failed fetch leaves nothing behind.
|
||||
//
|
||||
// npm's own error output (unknown package, network failure, auth) is included
|
||||
// in the returned error so the user sees why the fetch failed.
|
||||
func Fetch(ref PackageRef) (FetchResult, error) {
|
||||
if err := EnsureNPM(); err != nil {
|
||||
return FetchResult{}, err
|
||||
}
|
||||
|
||||
tmp, err := os.MkdirTemp("", "pigo-pkg-*")
|
||||
if err != nil {
|
||||
return FetchResult{}, fmt.Errorf("pkgmgr: create temp dir: %w", err)
|
||||
}
|
||||
// From here on, remove tmp on any error path.
|
||||
fail := func(e error) (FetchResult, error) {
|
||||
_ = os.RemoveAll(tmp)
|
||||
return FetchResult{}, e
|
||||
}
|
||||
|
||||
spec := ref.Name
|
||||
if ref.Version != "" {
|
||||
spec += "@" + ref.Version
|
||||
}
|
||||
|
||||
// `npm pack <spec>` writes a .tgz into --pack-destination and prints its
|
||||
// filename. --ignore-scripts guards against packing-time script execution.
|
||||
cmd := exec.Command(npmExecutable, "pack", spec,
|
||||
"--pack-destination", tmp,
|
||||
"--ignore-scripts",
|
||||
"--loglevel", "error")
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &stderr
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
msg := strings.TrimSpace(stderr.String())
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return fail(fmt.Errorf("npm pack %s failed: %s", spec, msg))
|
||||
}
|
||||
|
||||
tarball, err := locateTarball(tmp, out)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
|
||||
dest := filepath.Join(tmp, "extracted")
|
||||
if err := extractTarGz(tarball, dest); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
// npm packs into a top-level "package/" directory.
|
||||
pkgDir := filepath.Join(dest, "package")
|
||||
if _, err := os.Stat(pkgDir); err != nil {
|
||||
return fail(fmt.Errorf("pkgmgr: extracted tarball missing package/ dir: %w", err))
|
||||
}
|
||||
return FetchResult{Dir: pkgDir, TempRoot: tmp}, nil
|
||||
}
|
||||
|
||||
// locateTarball resolves the .tgz path that `npm pack` produced. npm prints the
|
||||
// tarball filename on stdout; when that is unhelpful we fall back to scanning
|
||||
// the destination directory for a single .tgz.
|
||||
func locateTarball(dir string, packStdout []byte) (string, error) {
|
||||
if name := strings.TrimSpace(string(packStdout)); name != "" {
|
||||
// npm may print just the filename; join to dir if it isn't absolute.
|
||||
cand := name
|
||||
if !filepath.IsAbs(cand) {
|
||||
cand = filepath.Join(dir, filepath.Base(name))
|
||||
}
|
||||
if _, err := os.Stat(cand); err == nil {
|
||||
return cand, nil
|
||||
}
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pkgmgr: read pack dir: %w", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if strings.HasSuffix(e.Name(), ".tgz") {
|
||||
return filepath.Join(dir, e.Name()), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("pkgmgr: npm pack produced no .tgz in %s", dir)
|
||||
}
|
||||
|
||||
// extractTarGz extracts a gzip-compressed tar archive into dest, creating dest.
|
||||
// It guards against path traversal (a "../" entry escaping dest) and skips any
|
||||
// entry that is not a regular file or directory.
|
||||
func extractTarGz(tarball, dest string) error {
|
||||
f, err := os.Open(tarball)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pkgmgr: open tarball: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pkgmgr: gzip reader: %w", err)
|
||||
}
|
||||
defer gz.Close()
|
||||
|
||||
if err := os.MkdirAll(dest, 0o755); err != nil {
|
||||
return fmt.Errorf("pkgmgr: create extract dir: %w", err)
|
||||
}
|
||||
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("pkgmgr: read tar: %w", err)
|
||||
}
|
||||
target, err := safeJoin(dest, hdr.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||
return fmt.Errorf("pkgmgr: mkdir %q: %w", target, err)
|
||||
}
|
||||
case tar.TypeReg:
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return fmt.Errorf("pkgmgr: mkdir parent of %q: %w", target, err)
|
||||
}
|
||||
if err := writeFile(target, tr, os.FileMode(hdr.Mode)); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
// Skip symlinks, devices, etc. — npm packages are files + dirs.
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// safeJoin joins name onto base, rejecting any result that escapes base (a
|
||||
// tarball path-traversal guard).
|
||||
func safeJoin(base, name string) (string, error) {
|
||||
target := filepath.Join(base, name)
|
||||
cleanBase := filepath.Clean(base) + string(os.PathSeparator)
|
||||
if target != filepath.Clean(base) && !strings.HasPrefix(target, cleanBase) {
|
||||
return "", fmt.Errorf("pkgmgr: tarball entry %q escapes extract dir", name)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// writeFile writes the tar entry body to target with the given mode.
|
||||
func writeFile(target string, r io.Reader, mode os.FileMode) error {
|
||||
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pkgmgr: create %q: %w", target, err)
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, r); err != nil {
|
||||
return fmt.Errorf("pkgmgr: write %q: %w", target, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user