diff --git a/cli/command/install/install.go b/cli/command/install/install.go index 6ad6b06c..ad500318 100644 --- a/cli/command/install/install.go +++ b/cli/command/install/install.go @@ -4,14 +4,17 @@ import ( "context" "fmt" "os" + "path/filepath" "strings" "sync" "wpm/cli/command" "wpm/cli/version" "wpm/pkg/output" + "wpm/pkg/pm/workspace" "wpm/pkg/pm/wpmjson" "wpm/pkg/pm/wpmjson/types" + "wpm/pkg/pm/wpmjson/validator" "github.com/morikuni/aec" "github.com/pkg/errors" @@ -81,6 +84,29 @@ func runInstall(ctx context.Context, wpmCli command.Cli, opts installOptions, pa return errors.Wrap(err, "failed to get current working directory") } + wpmCli.Output().Prettyln(output.Text{ + Plain: "wpm install v" + version.Version, + Fancy: aec.Bold.Apply("wpm install") + " " + aec.LightBlackF.Apply("v"+version.Version), + }) + + contentDir := wpmjson.New().ContentDir() + if probe, _ := wpmjson.Read(cwd); probe != nil { + contentDir = probe.ContentDir() + } + + lock, err := workspace.AcquireLock(ctx, filepath.Join(cwd, contentDir), func() { + wpmCli.Output().PrettyErrorln(output.Text{ + Plain: "waiting for another wpm process to finish in this workspace...", + Fancy: aec.Faint.Apply("waiting for another wpm process to finish in this workspace..."), + }) + }) + if err != nil { + return errors.Wrap(err, "failed to acquire workspace lock") + } + defer func() { + _ = lock.Release() + }() + cfg, err := wpmjson.Read(cwd) if err != nil { return err @@ -94,11 +120,6 @@ func runInstall(ctx context.Context, wpmCli command.Cli, opts installOptions, pa cfg = wpmjson.New() } - wpmCli.Output().Prettyln(output.Text{ - Plain: "wpm install v" + version.Version, - Fancy: aec.Bold.Apply("wpm install") + " " + aec.LightBlackF.Apply("v"+version.Version), - }) - configModified := false if len(packages) > 0 { @@ -162,7 +183,10 @@ func addPackages(ctx context.Context, config *wpmjson.Config, wpmCli command.Cli var mu sync.Mutex for i, pkgArg := range packages { - name, versionOrTag := parsePackageArg(pkgArg) + name, versionOrTag, err := parsePackageArg(pkgArg) + if err != nil { + return err + } progress.Stream(wpmCli.Err(), fmt.Sprintf(" Resolving %s@%s [%d/%d]", name, versionOrTag, i+1, len(packages))) @@ -196,10 +220,33 @@ func addPackages(ctx context.Context, config *wpmjson.Config, wpmCli command.Cli return g.Wait() } -func parsePackageArg(arg string) (string, string) { - lastAt := strings.LastIndex(arg, "@") - if lastAt > 0 { - return arg[:lastAt], arg[lastAt+1:] +func parsePackageArg(arg string) (string, string, error) { + if arg == "" { + return "", "", errors.New("package argument cannot be empty") + } + + name := arg + versionOrTag := "latest" + + if lastAt := strings.LastIndex(arg, "@"); lastAt > 0 { + name = arg[:lastAt] + versionOrTag = arg[lastAt+1:] + + if versionOrTag == "" { + versionOrTag = "latest" + } + } + + if err := validator.IsValidPackageName(name); err != nil { + return "", "", fmt.Errorf("invalid package name %q: %w", name, err) } - return arg, "latest" + + verErr := validator.IsValidVersion(versionOrTag) + tagErr := validator.IsValidDistTag(versionOrTag) + + if verErr != nil && tagErr != nil { + return "", "", fmt.Errorf("invalid version or tag %q: must be a valid semver or dist tag", versionOrTag) + } + + return name, versionOrTag, nil } diff --git a/cli/command/install/run.go b/cli/command/install/run.go index fe51e6b9..d401765d 100644 --- a/cli/command/install/run.go +++ b/cli/command/install/run.go @@ -63,6 +63,9 @@ func Run(ctx context.Context, cwd string, wpmCli command.Cli, opts RunOptions) e if wpmCfg == nil { return errors.New("wpm.json config is required") } + if err := wpmCfg.ValidateDependencyNames(); err != nil { + return errors.Wrap(err, "invalid dependency name in wpm.json") + } lock, err := wpmlock.Read(cwd) if err != nil { @@ -120,9 +123,14 @@ func Run(ctx context.Context, cwd string, wpmCli command.Cli, opts RunOptions) e } // -- Actual Install -- - inst := installer.New(absContentDir, opts.NetworkConcurrency, client, func(format string, args ...any) { + inst, err := installer.New(ctx, absContentDir, opts.NetworkConcurrency, client, func(format string, args ...any) { wpmCli.Output().ErrorWrite(fmt.Sprintf(format+"\n", args...)) }) + if err != nil { + return errors.Wrap(err, "failed to initialize installer") + } + defer inst.Close() + if err := inst.InstallAll(ctx, plan, installerProgress(wpmCli.Output())); err != nil { return errors.Wrap(err, "installation failed") } diff --git a/cli/command/publish/publish.go b/cli/command/publish/publish.go index 3b0c1a6d..68c329d9 100644 --- a/cli/command/publish/publish.go +++ b/cli/command/publish/publish.go @@ -27,7 +27,10 @@ import ( "github.com/spf13/cobra" ) -const maxReadmeSize = 50 * 1024 // 50KB +const ( + maxReadmeSize = 50 * 1024 // 50KB + maxPackedSize int64 = 128 * 1024 * 1024 // 128MB +) type publishOptions struct { dryRun bool @@ -121,9 +124,13 @@ func getReadme(dirPath string) (string, error) { type tarballSizeCounter struct { total int64 + limit int64 } func (c *tarballSizeCounter) Write(p []byte) (n int, err error) { + if c.limit > 0 && c.total+int64(len(p)) > c.limit { + return 0, fmt.Errorf("tarball size exceeds %d bytes, refusing to continue", c.limit) + } c.total += int64(len(p)) return len(p), nil } @@ -169,9 +176,10 @@ func runPublish(ctx context.Context, wpmCli command.Cli, opts publishOptions) er if err != nil { return errors.Wrap(err, "failed to pack the package into a tarball") } + defer tarballer.Close() hasher := sha256.New() - counter := &tarballSizeCounter{} + counter := &tarballSizeCounter{limit: maxPackedSize} multiWriter := io.MultiWriter(tempFile, hasher, counter) packTarball := func() error { @@ -202,10 +210,6 @@ func runPublish(ctx context.Context, wpmCli command.Cli, opts publishOptions) er return errors.New("tarball size is zero, cannot publish empty package") } - if counter.total > 128*1024*1024 { - return errors.New("tarball size exceeds 128mb, cannot publish package") - } - dim := aec.Faint.Apply blue := aec.LightBlueF.Apply w := tabwriter.NewWriter(wpmCli.Err(), 0, 0, 2, ' ', 0) diff --git a/cli/command/uninstall/uninstall.go b/cli/command/uninstall/uninstall.go index 8631fec9..5d3adb18 100644 --- a/cli/command/uninstall/uninstall.go +++ b/cli/command/uninstall/uninstall.go @@ -4,15 +4,18 @@ import ( "context" "fmt" "os" + "path/filepath" "wpm/cli" "wpm/cli/command" "wpm/cli/command/install" "wpm/cli/version" "wpm/pkg/output" + "wpm/pkg/pm/workspace" "wpm/pkg/pm/wpmjson" "github.com/morikuni/aec" + "github.com/pkg/errors" "github.com/spf13/cobra" ) @@ -37,6 +40,29 @@ func runUninstall(ctx context.Context, wpmCli command.Cli, packages []string) er return err } + wpmCli.Output().Prettyln(output.Text{ + Plain: "wpm uninstall v" + version.Version, + Fancy: aec.Bold.Apply("wpm uninstall") + " " + aec.LightBlackF.Apply("v"+version.Version), + }) + + contentDir := wpmjson.New().ContentDir() + if probe, _ := wpmjson.Read(cwd); probe != nil { + contentDir = probe.ContentDir() + } + + lock, err := workspace.AcquireLock(ctx, filepath.Join(cwd, contentDir), func() { + wpmCli.Output().PrettyErrorln(output.Text{ + Plain: "waiting for another wpm process to finish in this workspace...", + Fancy: aec.Faint.Apply("waiting for another wpm process to finish in this workspace..."), + }) + }) + if err != nil { + return errors.Wrap(err, "failed to acquire workspace lock") + } + defer func() { + _ = lock.Release() + }() + cfg, err := wpmjson.Read(cwd) if err != nil { return err @@ -46,11 +72,6 @@ func runUninstall(ctx context.Context, wpmCli command.Cli, packages []string) er return fmt.Errorf("no wpm.json found, so nothing to uninstall") } - wpmCli.Output().Prettyln(output.Text{ - Plain: "wpm uninstall v" + version.Version, - Fancy: aec.Bold.Apply("wpm uninstall") + " " + aec.LightBlackF.Apply("v"+version.Version), - }) - changed := false for _, name := range packages { if cfg.Dependencies != nil { diff --git a/go.mod b/go.mod index 0ef0c9fa..beb107b3 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/containerd/errdefs v1.0.0 github.com/docker/go-units v0.5.0 github.com/fvbommel/sortorder v1.1.0 + github.com/gofrs/flock v0.13.0 github.com/henvic/httpretty v0.1.4 github.com/klauspost/compress v1.18.6 github.com/moby/patternmatcher v0.6.1 diff --git a/go.sum b/go.sum index 49318374..dc7cbca0 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,8 @@ github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQmYw= github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0= +github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= +github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/henvic/httpretty v0.1.4 h1:Jo7uwIRWVFxkqOnErcoYfH90o3ddQyVrSANeS4cxYmU= github.com/henvic/httpretty v0.1.4/go.mod h1:Dn60sQTZfbt2dYsdUSNsCljyF4AfdqnuJFDLJA1I4AM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -47,8 +49,8 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/thlib/go-timezone-local v0.0.7 h1:fX8zd3aJydqLlTs/TrROrIIdztzsdFV23OzOQx31jII= github.com/thlib/go-timezone-local v0.0.7/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= diff --git a/pkg/api/cache.go b/pkg/api/cache.go index 42033663..a1347d45 100644 --- a/pkg/api/cache.go +++ b/pkg/api/cache.go @@ -1,7 +1,6 @@ package api import ( - "context" "crypto/sha256" "encoding/binary" "encoding/hex" @@ -9,19 +8,22 @@ import ( "errors" "fmt" "io" + "io/fs" "net/http" "os" "path/filepath" - "sync" "time" ) const ( + headerMagic = 0x57504D43 + footerMagic = 0x57504D5E + + // Meta length sanity check: 512KB should be more than enough for headers. + maxMetaLen = 512 * 1024 + + // Bump cache version to invalidate old caches after metadata format change. cacheVersion = "wpm-cache-v1" - headerMagic = 0x57504D43 - footerMagic = 0x57504D5E - lockTimeout = 60 * time.Second - maxLockAge = 5 * time.Minute ) var cacheableHeaders = []string{ @@ -34,7 +36,6 @@ var cacheableHeaders = []string{ type Transport struct { Base http.RoundTripper - sf singleflight cacheDir string } @@ -45,7 +46,7 @@ type meta struct { func CleanupStale(cacheDir string) error { tmpDir := filepath.Join(cacheDir, "tmp") entries, err := os.ReadDir(tmpDir) - if os.IsNotExist(err) { + if errors.Is(err, fs.ErrNotExist) { return nil } if err != nil { @@ -56,7 +57,7 @@ func CleanupStale(cacheDir string) error { for _, e := range entries { info, err := e.Info() if err == nil && info.ModTime().Before(threshold) { - os.Remove(filepath.Join(tmpDir, e.Name())) + _ = os.Remove(filepath.Join(tmpDir, e.Name())) } } return nil @@ -82,7 +83,7 @@ func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { hash := sha256.Sum256([]byte(outReq.URL.String())) key := hex.EncodeToString(hash[:]) - // Sharding: cache/a1/b2/key + // Sharding: cache/aa/bb/ to avoid too many files in a single directory. finalPath := filepath.Join(t.cacheDir, key[:2], key[2:4], key) if !force { @@ -92,62 +93,27 @@ func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { } } - res, err, shared := t.sf.Do(key, func() (any, error) { - return t.executeRequest(outReq, finalPath, force) - }) - + res, err := t.executeRequest(outReq, finalPath, force) if err != nil { return t.base().RoundTrip(outReq) } - resp := res.(*http.Response) - - // If this request was shared, another goroutine may have cached it - // while we were waiting. In that case, we re-open the cache to - // ensure we return a cached response. - if shared { - if body, h, err := t.open(finalPath); err == nil { - h.Set(HeaderLocalCache, CacheHit) - return t.response(outReq, body, h), nil - } - - // If file is not ready, fall back to fresh network request. - // We do NOT cache this fallback to avoid race conditions. - return t.base().RoundTrip(outReq) - } - - return resp, nil + return res, nil } -func (t *Transport) executeRequest(req *http.Request, path string, force bool) (*http.Response, error) { - unlock, err := t.acquireLock(path + ".lock") - if err != nil { +func (t *Transport) executeRequest(req *http.Request, finalPath string, force bool) (*http.Response, error) { + if err := os.MkdirAll(filepath.Dir(finalPath), 0o755); err != nil { return t.base().RoundTrip(req) } - lockHeld := true - defer func() { - if lockHeld { - unlock() - } - }() - - if !force { - if body, h, err := t.open(path); err == nil { - h.Set(HeaderLocalCache, CacheHit) - return t.response(req, body, h), nil - } - } - if force { - if body, h, err := t.open(path); err == nil { + if body, h, err := t.open(finalPath); err == nil { if lm := h.Get(HeaderLastModified); lm != "" { req.Header.Set(HeaderIfModifiedSince, lm) } if et := h.Get(HeaderEtag); et != "" { req.Header.Set(HeaderIfNoneMatch, et) } - body.Close() } } @@ -160,12 +126,13 @@ func (t *Transport) executeRequest(req *http.Request, path string, force bool) ( // Handle 304 Not Modified if resp.StatusCode == http.StatusNotModified { resp.Body.Close() - if b, h, err := t.open(path); err == nil { + if body, h, err := t.open(finalPath); err == nil { h.Set(HeaderLocalCache, CacheHit) - return t.response(req, b, h), nil + return t.response(req, body, h), nil } - // Cache file missing/corrupt, redo the request without conditions + // Cache file missing/corrupt between conditional request and read. + // Retry without conditional headers. req.Header.Del(HeaderIfNoneMatch) req.Header.Del(HeaderIfModifiedSince) resp, err = t.base().RoundTrip(req) @@ -175,9 +142,7 @@ func (t *Transport) executeRequest(req *http.Request, path string, force bool) ( } if resp.StatusCode == http.StatusOK { - wrappedBody := t.write(resp.Body, path, resp.Header, unlock) - resp.Body = wrappedBody - lockHeld = false + resp.Body = t.write(resp.Body, finalPath, resp.Header) } return resp, nil @@ -208,6 +173,9 @@ func (t *Transport) open(path string) (io.ReadCloser, http.Header, error) { if err := binary.Read(f, binary.BigEndian, &metaLen); err != nil { return fail(err) } + if metaLen > maxMetaLen { + return fail(fmt.Errorf("meta length %d exceeds %d limit", metaLen, maxMetaLen)) + } rawMeta := make([]byte, metaLen) if _, err := io.ReadFull(f, rawMeta); err != nil { @@ -229,7 +197,6 @@ func (t *Transport) open(path string) (io.ReadCloser, http.Header, error) { return fail(err) } - // Ensure file ends with valid footer if stat.Size() < bodyStart+4 { return fail(errors.New("truncated")) } @@ -253,41 +220,30 @@ func (t *Transport) open(path string) (io.ReadCloser, http.Header, error) { }, m.Headers, nil } -func (t *Transport) write(src io.ReadCloser, finalPath string, h http.Header, unlock func()) io.ReadCloser { - rootDir := filepath.Dir(filepath.Dir(filepath.Dir(finalPath))) - tmpDir := filepath.Join(rootDir, "tmp") - - if err := os.MkdirAll(tmpDir, 0755); err != nil { - unlock() +func (t *Transport) write(src io.ReadCloser, finalPath string, h http.Header) io.ReadCloser { + tmpDir := filepath.Join(t.cacheDir, "tmp") + if err := os.MkdirAll(tmpDir, 0o755); err != nil { return src } - if err := os.MkdirAll(filepath.Dir(finalPath), 0755); err != nil { - unlock() - return src - } - - tmpName := fmt.Sprintf("%x.%d.tmp", sha256.Sum256([]byte(finalPath)), time.Now().UnixNano()) - tmpPath := filepath.Join(tmpDir, tmpName) - f, err := os.Create(tmpPath) + f, err := os.CreateTemp(tmpDir, "cache-*.tmp") if err != nil { - unlock() return src } + _ = os.Chmod(f.Name(), 0o644) + if err := t.writeMeta(f, h); err != nil { f.Close() - os.Remove(tmpPath) - unlock() + os.Remove(f.Name()) return src } return &writer{ - src: src, - dst: f, - tmp: tmpPath, - final: finalPath, - unlock: unlock, + src: src, + dst: f, + tmp: f.Name(), + final: finalPath, } } @@ -299,21 +255,22 @@ func (t *Transport) writeMeta(w io.Writer, h http.Header) error { return err } - metaHeaders := http.Header{} + cacheable := http.Header{} for _, key := range cacheableHeaders { if values, ok := h[key]; ok { for _, v := range values { - metaHeaders.Add(key, v) + cacheable.Add(key, v) } } } - h = metaHeaders - - b, err := json.Marshal(meta{Headers: h}) + b, err := json.Marshal(meta{Headers: cacheable}) if err != nil { return err } + if len(b) > maxMetaLen { + return fmt.Errorf("cacheable headers too large: %d bytes", len(b)) + } if err := binary.Write(w, binary.BigEndian, uint32(len(b))); err != nil { return err } @@ -326,23 +283,19 @@ type writer struct { dst *os.File tmp string final string - unlock func() hitEOF bool cacheFailed bool } func (w *writer) Read(p []byte) (int, error) { n, err := w.src.Read(p) - if n > 0 && !w.cacheFailed { if _, wErr := w.dst.Write(p[:n]); wErr != nil { - // On write failure, stop caching and clean up w.cacheFailed = true w.dst.Close() os.Remove(w.tmp) } } - if err == io.EOF { w.hitEOF = true } @@ -350,88 +303,50 @@ func (w *writer) Read(p []byte) (int, error) { } func (w *writer) Close() error { - defer w.unlock() - srcErr := w.src.Close() - success := srcErr == nil && w.hitEOF && !w.cacheFailed + if w.cacheFailed { + return srcErr + } - var footerErr error + success := srcErr == nil && w.hitEOF if success { - footerErr = binary.Write(w.dst, binary.BigEndian, uint32(footerMagic)) + if err := binary.Write(w.dst, binary.BigEndian, uint32(footerMagic)); err != nil { + success = false + } } - if !w.cacheFailed { - dstErr := w.dst.Close() - - if success && footerErr == nil && dstErr == nil { - if err := renameFile(w.tmp, w.final); err != nil { - os.Remove(w.tmp) - } - } else { - os.Remove(w.tmp) - } - } else { + closeErr := w.dst.Close() + if !success || closeErr != nil { os.Remove(w.tmp) + return srcErr } + if err := renameFile(w.tmp, w.final); err != nil { + os.Remove(w.tmp) + } return srcErr } -// rename with retry for Windows compatibility +// renameFile retries on transient Windows errors (file locked by reader, +// AV scanner). Exponential backoff up to ~3.5s. func renameFile(src, dst string) error { - for range 5 { - err := os.Rename(src, dst) + const maxAttempts = 8 + backoff := 25 * time.Millisecond + + var err error + for i := range maxAttempts { + if i > 0 { + time.Sleep(backoff) + if backoff < time.Second { + backoff *= 2 + } + } + err = os.Rename(src, dst) if err == nil { return nil } - - // On Windows, if the file is locked by another process (Reader), - // both Remove and Rename will fail with "Access Denied". - // We retry in hopes the Reader finishes or the AV scanner releases it. - time.Sleep(50 * time.Millisecond) - } - - // Final attempt: remove destination first - os.Remove(dst) - return os.Rename(src, dst) -} - -func (t *Transport) acquireLock(path string) (func(), error) { - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return nil, err - } - - ctx, cancel := context.WithTimeout(context.Background(), lockTimeout) - defer cancel() - - ticker := time.NewTicker(50 * time.Millisecond) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-ticker.C: - // O_EXCL ensures atomic check-and-create - f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL, 0666) - if err == nil { - f.Close() - return func() { os.Remove(path) }, nil - } - - // If the lock file exists, check its age - if os.IsExist(err) { - if info, statErr := os.Stat(path); statErr == nil { - if time.Since(info.ModTime()) > maxLockAge { - os.Remove(path) - continue - } - } - } else { - return nil, err - } - } } + return err } func (t *Transport) response(req *http.Request, body io.ReadCloser, h http.Header) *http.Response { @@ -459,39 +374,3 @@ type safeReader struct { func (s *safeReader) Close() error { return s.f.Close() } - -type singleflight struct { - mu sync.Mutex - m map[string]*call -} - -type call struct { - wg sync.WaitGroup - val any - err error -} - -func (g *singleflight) Do(key string, fn func() (any, error)) (any, error, bool) { - g.mu.Lock() - if g.m == nil { - g.m = make(map[string]*call) - } - if c, ok := g.m[key]; ok { - g.mu.Unlock() - c.wg.Wait() - return c.val, c.err, true - } - c := new(call) - c.wg.Add(1) - g.m[key] = c - g.mu.Unlock() - - c.val, c.err = fn() - c.wg.Done() - - g.mu.Lock() - delete(g.m, key) - g.mu.Unlock() - - return c.val, c.err, false -} diff --git a/pkg/api/http_client.go b/pkg/api/http_client.go index 57ddc91f..68dd23df 100644 --- a/pkg/api/http_client.go +++ b/pkg/api/http_client.go @@ -75,8 +75,20 @@ func NewHTTPClient(opts ClientOptions) (*http.Client, error) { cacheDir: opts.CacheDir, } + if opts.Headers == nil { + opts.Headers = map[string]string{} + } + + if !opts.SkipDefaultHeaders { + resolveHeaders(opts.Headers) + } + var rt http.RoundTripper = transport + rt = newHeaderRoundTripper(opts.Host, opts.AuthToken, opts.Headers, rt) + rt = newDecompressingRoundTripper(rt) + rt = newSanitizerRoundTripper(rt) + if opts.Log != nil && logrus.GetLevel() == logrus.DebugLevel { opts.LogVerboseHTTP = true logger := &httpretty.Logger{ @@ -97,18 +109,6 @@ func NewHTTPClient(opts ClientOptions) (*http.Client, error) { rt = logger.RoundTripper(rt) } - if opts.Headers == nil { - opts.Headers = map[string]string{} - } - - if !opts.SkipDefaultHeaders { - resolveHeaders(opts.Headers) - } - - rt = newHeaderRoundTripper(opts.Host, opts.AuthToken, opts.Headers, rt) - rt = newDecompressingRoundTripper(rt) - rt = newSanitizerRoundTripper(rt) - return &http.Client{Transport: rt, Timeout: opts.Timeout}, nil } @@ -244,6 +244,7 @@ func (z *zstdReadCloser) Read(p []byte) (n int, err error) { func (z *zstdReadCloser) Close() error { err := z.OriginalBody.Close() + _ = z.Decoder.Reset(nil) zstdDecoderPool.Put(z.Decoder) return err } diff --git a/pkg/archive/archive.go b/pkg/archive/archive.go index 7ffce6a5..f33b915e 100644 --- a/pkg/archive/archive.go +++ b/pkg/archive/archive.go @@ -23,12 +23,16 @@ import ( ) const ( - ImpliedDirectoryMode = 0o755 + regularFileMode = 0o644 + impliedDirectoryMode = 0o755 + zstdMagicSkippableStart = 0x184D2A50 zstdMagicSkippableMask = 0xFFFFFFF0 zstdMaxWindowSize = uint64(1 << 25) // 32 MB + maxCompressedSize int64 = 128 * 1024 * 1024 // 128 MB + maxCompressionRatio int64 = 250 ratioCheckThreshold int64 = 5 * 1024 * 1024 // 5 MB maxDecompressedSize int64 = 512 * 1024 * 1024 // 512 MB @@ -103,52 +107,55 @@ func (r *readCloserWrapper) Close() error { } var ( - bufioReader1MPool = &sync.Pool{ - New: func() interface{} { return bufio.NewReaderSize(nil, 1024*1024) }, + bufioReader256KPool = &sync.Pool{ + New: func() any { return bufio.NewReaderSize(nil, 256*1024) }, } ) type bufferedReader struct { - buf *bufio.Reader + buf *bufio.Reader + closed atomic.Bool } func newBufferedReader(r io.Reader) *bufferedReader { - buf := bufioReader1MPool.Get().(*bufio.Reader) + buf := bufioReader256KPool.Get().(*bufio.Reader) buf.Reset(r) - return &bufferedReader{buf} + return &bufferedReader{buf: buf} } func (r *bufferedReader) Read(p []byte) (n int, err error) { - if r.buf == nil { + if r.closed.Load() { return 0, io.EOF } n, err = r.buf.Read(p) if err == io.EOF { - r.buf.Reset(nil) - bufioReader1MPool.Put(r.buf) - r.buf = nil + r.Close() } return } func (r *bufferedReader) Peek(n int) ([]byte, error) { - if r.buf == nil { + if r.closed.Load() { return nil, io.EOF } return r.buf.Peek(n) } +func (r *bufferedReader) Close() error { + if !r.closed.CompareAndSwap(false, true) { + return nil + } + r.buf.Reset(nil) + bufioReader256KPool.Put(r.buf) + r.buf = nil + return nil +} + // DecompressStream decompresses the archive and returns a ReaderCloser with the decompressed archive. func DecompressStream(archive io.Reader) (io.ReadCloser, error) { buf := newBufferedReader(archive) bs, err := buf.Peek(10) if err != nil && err != io.EOF { - // Note: we'll ignore any io.EOF error because there are some odd - // cases where the layer.tar file will be empty (zero bytes) and - // that results in an io.EOF from the Peek() call. So, in those - // cases we'll just treat it as a non-compressed stream and - // that means just create an empty layer. - // See Issue 18170 return nil, err } @@ -166,7 +173,7 @@ func DecompressStream(archive io.Reader) (io.ReadCloser, error) { Reader: zstdReader, closer: func() error { zstdReader.Close() - return nil + return buf.Close() }, }, nil } @@ -180,9 +187,9 @@ func FileInfoHeader(name string, fi os.FileInfo, link string) (*tar.Header, erro var newPerms os.FileMode if fi.IsDir() { - newPerms = ImpliedDirectoryMode + newPerms = impliedDirectoryMode } else if fi.Mode().IsRegular() { - newPerms = 0o644 + newPerms = regularFileMode } if newPerms != 0 { @@ -284,14 +291,12 @@ func (ta *tarAppender) addTarFile(path, name string) error { } func createTarFile(path, extractDir string, hdr *tar.Header, reader io.Reader, options *TarOptions) error { - hdrInfo := hdr.FileInfo() - switch hdr.Typeflag { case tar.TypeDir: // Create directory unless it exists as a directory already. // In that case we just want to merge the two if fi, err := os.Lstat(path); err != nil || !fi.IsDir() { - if err := os.Mkdir(path, hdrInfo.Mode()); err != nil { + if err := os.Mkdir(path, impliedDirectoryMode); err != nil { return err } } @@ -299,11 +304,11 @@ func createTarFile(path, extractDir string, hdr *tar.Header, reader io.Reader, o case tar.TypeReg: // Source is regular file. We use sequential file access to avoid depleting // the standby list on Windows. On Linux, this equates to a regular os.OpenFile. - file, err := sequential.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, hdrInfo.Mode()) + file, err := sequential.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, regularFileMode) if err != nil { return err } - if _, err := copyWithBuffer(file, reader); err != nil { + if _, err := copyWithBuffer(file, io.LimitReader(reader, hdr.Size)); err != nil { file.Close() return err } @@ -407,6 +412,11 @@ func (t *Tarballer) UnpackedSize() int64 { return t.unpackedSize.Load() } +// Close closes the reader and writer of the Tarballer. +func (t *Tarballer) Close() error { + return t.pipeReader.Close() +} + // Do performs the archiving operation in the background. The resulting archive // can be read from t.Reader(). Do should only be called once on each Tarballer // instance. @@ -442,13 +452,14 @@ func (t *Tarballer) Do() { return } - if len(t.options.IncludeFiles) == 0 { - t.options.IncludeFiles = []string{"."} + includeFiles := t.options.IncludeFiles + if len(includeFiles) == 0 { + includeFiles = []string{"."} } seen := make(map[string]bool) - for _, include := range t.options.IncludeFiles { + for _, include := range includeFiles { var ( parentMatchInfo []patternmatcher.MatchInfo parentDirs []string @@ -489,8 +500,7 @@ func (t *Tarballer) Do() { skip, matchInfo, err = t.pm.MatchesUsingParentResults(relFilePath, patternmatcher.MatchInfo{}) } if err != nil { - doErr = fmt.Errorf("error matching %q: %w", relFilePath, err) - return err + return fmt.Errorf("error matching %q: %w", relFilePath, err) } if f.IsDir() { @@ -572,6 +582,7 @@ func Unpack(decompressedArchive io.Reader, dest string, options *TarOptions) err tr := tar.NewReader(decompressedArchive) var dirs []*tar.Header + var totalSize int64 // Iterate through the files in the archive. loop: @@ -600,6 +611,19 @@ loop: return breakoutError(fmt.Errorf("invalid archive: insecure path %q (potential directory traversal)", hdr.Name)) } + if hdr.Size < 0 { + return fmt.Errorf("invalid archive: negative size %d for %q", hdr.Size, hdr.Name) + } + + if hdr.Size > maxDecompressedSize { + return fmt.Errorf("invalid archive: entry %q declares size %d exceeding %d limit", hdr.Name, hdr.Size, maxDecompressedSize) + } + + totalSize += hdr.Size + if totalSize > maxDecompressedSize { + return fmt.Errorf("invalid archive: total declared size exceeds %d limit", maxDecompressedSize) + } + for _, exclude := range options.ExcludePatterns { if strings.HasPrefix(filepath.ToSlash(hdr.Name), filepath.ToSlash(exclude)) { continue loop @@ -673,7 +697,7 @@ func createImpliedDirectories(dest string, hdr *tar.Header) error { parent := filepath.Dir(hdr.Name) parentPath := filepath.Join(dest, parent) if _, err := os.Lstat(parentPath); err != nil && os.IsNotExist(err) { - err = os.MkdirAll(parentPath, ImpliedDirectoryMode) + err = os.MkdirAll(parentPath, impliedDirectoryMode) if err != nil { return err } @@ -703,30 +727,27 @@ type extractionLimiter struct { } func (b *extractionLimiter) Read(p []byte) (int, error) { - if b.decompressedBytes > maxDecompressedSize { + if b.compressedTracker.bytesRead > maxCompressedSize { + return 0, fmt.Errorf("invalid archive: compressed size exceeds 128MB limit") + } + if b.decompressedBytes >= maxDecompressedSize { return 0, fmt.Errorf("invalid archive: decompressed size exceeds 512MB limit (potential zip bomb)") } remaining := maxDecompressedSize - b.decompressedBytes readBuf := p - if int64(len(readBuf)) > remaining { - readBuf = readBuf[:remaining+1] + readBuf = readBuf[:remaining] } n, err := b.decompressedStream.Read(readBuf) b.decompressedBytes += int64(n) - if b.decompressedBytes > maxDecompressedSize { - return n, fmt.Errorf("invalid archive: decompressed size exceeds 512MB limit (potential zip bomb)") - } - if b.decompressedBytes > ratioCheckThreshold { cBytes := b.compressedTracker.bytesRead if cBytes == 0 { cBytes = 1 // prevent division by zero } - if b.decompressedBytes >= int64(maxCompressionRatio)*cBytes { return n, fmt.Errorf("invalid archive: compression ratio exceeds 99.6%% (potential zip bomb)") } diff --git a/pkg/pm/installer/installer.go b/pkg/pm/installer/installer.go index a158e8a4..16884829 100644 --- a/pkg/pm/installer/installer.go +++ b/pkg/pm/installer/installer.go @@ -2,10 +2,13 @@ package installer import ( "context" + "crypto/rand" "crypto/sha256" "encoding/base64" + "encoding/hex" "fmt" "io" + "io/fs" "os" "path/filepath" "runtime" @@ -17,36 +20,86 @@ import ( "wpm/pkg/pm/registry" "wpm/pkg/pm/signatures" "wpm/pkg/pm/wpmjson/types" + "wpm/pkg/pm/wpmjson/validator" "github.com/pkg/errors" "golang.org/x/sync/errgroup" ) +const ( + errorAccessDenied = syscall.Errno(5) // Windows ERROR_ACCESS_DENIED + errorNotSameDevice = syscall.Errno(17) // Windows ERROR_NOT_SAME_DEVICE + errorSharingViolation = syscall.Errno(32) // Windows ERROR_SHARING_VIOLATION +) + type Installer struct { concurrency int contentDir string tmpDir string - client registry.Client - extractSem chan struct{} - keysJson signatures.KeysJson - logger func(format string, args ...any) + runDir string + + client registry.Client + extractSem chan struct{} + keysJson signatures.KeysJson + logger func(format string, args ...any) } -func New(contentDir string, concurrency int, client registry.Client, logger func(format string, args ...any)) *Installer { +func New( + ctx context.Context, + contentDir string, + concurrency int, + client registry.Client, + logger func(format string, args ...any), +) (*Installer, error) { if concurrency <= 0 { concurrency = 16 } + if err := os.MkdirAll(contentDir, 0o755); err != nil { + return nil, errors.Wrap(err, "failed to create content directory") + } + tmpDir := filepath.Join(contentDir, ".tmp") - _ = os.MkdirAll(tmpDir, 0755) + if err := os.MkdirAll(tmpDir, 0o755); err != nil { + return nil, errors.Wrap(err, "failed to create tmp directory") + } + + sweepStaleRunDirs(tmpDir) + + runDir, err := os.MkdirTemp(tmpDir, "run-") + if err != nil { + return nil, errors.Wrap(err, "failed to create staging directory") + } return &Installer{ client: client, contentDir: contentDir, tmpDir: tmpDir, + runDir: runDir, concurrency: concurrency, extractSem: make(chan struct{}, max(runtime.NumCPU(), 1)), logger: logger, + }, nil +} + +func (i *Installer) Close() error { + if i == nil { + return nil + } + return os.RemoveAll(i.tmpDir) +} + +// Caller must hold the project lock. +func sweepStaleRunDirs(tmpDir string) { + entries, err := os.ReadDir(tmpDir) + if err != nil { + return + } + for _, e := range entries { + if !e.IsDir() || !strings.HasPrefix(e.Name(), "run-") { + continue + } + _ = os.RemoveAll(filepath.Join(tmpDir, e.Name())) } } @@ -55,7 +108,6 @@ func (i *Installer) InstallAll(ctx context.Context, plan []Action, progressFn fu if err != nil { return errors.Wrap(err, "failed to fetch public keys for signature verification") } - i.keysJson = keys g, ctx := errgroup.WithContext(ctx) @@ -63,30 +115,29 @@ func (i *Installer) InstallAll(ctx context.Context, plan []Action, progressFn fu for _, action := range plan { g.Go(func() error { - if ctx.Err() != nil { - return ctx.Err() + if err := ctx.Err(); err != nil { + return err } - err := i.Install(ctx, action) + err := i.install(ctx, action) if err == nil && progressFn != nil { progressFn(action) } - return err }) } - - err = g.Wait() - os.RemoveAll(i.tmpDir) - return err + return g.Wait() } -func (i *Installer) Install(ctx context.Context, action Action) error { - targetDir := i.getTargetDir(action.PkgType, action.Name) +func (i *Installer) install(ctx context.Context, action Action) error { + targetDir, err := i.getTargetDir(action.PkgType, action.Name) + if err != nil { + return err + } switch action.Type { case ActionRemove: - if err := i.removeAll(targetDir); err != nil { + if err := i.removeAll(ctx, targetDir); err != nil { return errors.Wrapf(err, "failed to delete %s", targetDir) } return nil @@ -98,7 +149,7 @@ func (i *Installer) Install(ctx context.Context, action Action) error { } func (i *Installer) installOrUpdate(ctx context.Context, action Action, targetDir string) error { - manifest, err := i.client.GetPackageManifest(ctx, action.Name, action.Version, true) + manifest, err := i.client.GetPackageManifest(ctx, action.Name, action.Version, false) if err != nil { return errors.Wrapf(err, "failed to fetch manifest for %s@%s", action.Name, action.Version) } @@ -136,34 +187,32 @@ func (i *Installer) installOrUpdate(ctx context.Context, action Action, targetDi extractedPath, tempContainer, err := i.unpackToStaging(stream) defer func() { - _ = i.removeAll(tempContainer) + _ = i.removeAll(context.Background(), tempContainer) }() - if err != nil { return errors.Wrap(err, "failed to unpack package") } + if _, err := io.Copy(io.Discard, stream); err != nil { + return errors.Wrap(err, "failed to drain download stream") + } + cleanDigest := strings.TrimPrefix(action.Digest, "sha256:") calculated := base64.StdEncoding.EncodeToString(hasher.Sum(nil)) if calculated != cleanDigest { return errors.Errorf("digest mismatch: expected %s, got %s", cleanDigest, calculated) } - return i.replaceDir(extractedPath, targetDir) + return i.replaceDir(ctx, extractedPath, targetDir) } -// unpackToStaging extracts to a temporary directory inside .tmp. -// Returns the path to the inner single-root folder, the path to the outer temp container, and error. func (i *Installer) unpackToStaging(r io.Reader) (string, string, error) { - rootTemp, err := os.MkdirTemp(i.tmpDir, "wpm-pkg-*") + rootTemp, err := os.MkdirTemp(i.runDir, "pkg-*") if err != nil { - return "", "", errors.Wrap(err, "failed to create temporary directory") - } - - opts := &archive.TarOptions{ - Logger: i.logger, + return "", "", errors.Wrap(err, "failed to create staging directory") } + opts := &archive.TarOptions{Logger: i.logger} if err := archive.Untar(r, rootTemp, opts); err != nil { return "", rootTemp, errors.Wrap(err, "failed to extract tarball") } @@ -173,7 +222,6 @@ func (i *Installer) unpackToStaging(r io.Reader) (string, string, error) { return "", rootTemp, err } - // Strict Single Root Check if len(entries) != 1 || !entries[0].IsDir() { return "", rootTemp, errors.New("invalid package structure: expected exactly one root directory") } @@ -181,62 +229,110 @@ func (i *Installer) unpackToStaging(r io.Reader) (string, string, error) { return filepath.Join(rootTemp, entries[0].Name()), rootTemp, nil } -// replaceDir atomically replaces targetDir -func (i *Installer) replaceDir(sourceDir, targetDir string) error { - if err := os.MkdirAll(filepath.Dir(targetDir), 0755); err != nil { - return err +func (i *Installer) replaceDir(ctx context.Context, sourceDir, targetDir string) error { + if err := os.MkdirAll(filepath.Dir(targetDir), 0o755); err != nil { + return errors.Wrap(err, "failed to create parent directory") + } + + if _, err := os.Lstat(targetDir); errors.Is(err, fs.ErrNotExist) { + return i.rename(ctx, sourceDir, targetDir) } - if _, err := os.Stat(targetDir); os.IsNotExist(err) { - return i.rename(sourceDir, targetDir) + var nonce [8]byte + if _, err := rand.Read(nonce[:]); err != nil { + return errors.Wrap(err, "failed to generate backup nonce") } + backupDir := filepath.Join(i.runDir, filepath.Base(targetDir)+".bak-"+hex.EncodeToString(nonce[:])) - backupPath := targetDir + ".bak." + fmt.Sprint(time.Now().UnixNano()) - if err := i.rename(targetDir, backupPath); err != nil { + if err := i.rename(ctx, targetDir, backupDir); err != nil { return errors.Wrap(err, "failed to move existing package to backup") } - if err := i.rename(sourceDir, targetDir); err != nil { - // ROLLBACK: Try to restore backup - _ = i.rename(backupPath, targetDir) + if err := i.rename(ctx, sourceDir, targetDir); err != nil { + if rbErr := i.rename(context.Background(), backupDir, targetDir); rbErr != nil { + return errors.Wrapf( + err, + "failed to install new version AND failed to roll back: previous version preserved at %q (rollback error: %v)", + backupDir, rbErr, + ) + } return errors.Wrap(err, "failed to install new version, rolled back") } - go func() { - _ = i.removeAll(backupPath) - }() - + _ = i.removeAll(ctx, backupDir) return nil } -// rename with retries for Windows file locking stability -func (i *Installer) rename(src, dst string) error { - var err error - for attempt := range 5 { - err = os.Rename(src, dst) - if err == nil { +func (i *Installer) rename(ctx context.Context, src, dst string) error { + return retryFS(ctx, func() error { + err := os.Rename(src, dst) + if err != nil && isCrossDeviceError(err) { + return errors.Errorf( + "cannot move %q to %q: source and destination are on different filesystems. "+ + "wpm requires the staging area (%s) and the install target (%s) to live on the same volume. "+ + "This typically affects Docker setups where individual plugin/theme directories are bind-mounted", + src, dst, i.tmpDir, dst, + ) + } + return err + }, isRetriableError) +} + +func (i *Installer) removeAll(ctx context.Context, path string) error { + if path == "" { + return nil + } + return retryFS(ctx, func() error { + err := os.RemoveAll(path) + if err == nil || errors.Is(err, fs.ErrNotExist) { return nil } + return err + }, isRetriableError) +} - if isLinkError(err) { - return err +// retryFS handles transient Windows file-locking errors (AV scanners, the +// indexer, web-server workers holding plugin files open during a swap). +// Exponential backoff up to ~5s; the budget is sized to outlast typical +// AV scan windows without making clean failures feel slow. +func retryFS(ctx context.Context, op func() error, retriable func(error) bool) error { + const maxAttempts = 8 + backoff := 25 * time.Millisecond + + var err error + for attempt := range maxAttempts { + if attempt > 0 { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(backoff): + } + if backoff < time.Second { + backoff *= 2 + } } - if !isRetriableError(err) { + err = op() + if err == nil || !retriable(err) { return err } + } + return err +} - time.Sleep(50 * time.Millisecond * time.Duration(attempt+1)) +func isRetriableError(err error) bool { + if err == nil { + return false + } - // On the 4th attempt, try to force GC to release file handles on Windows - if attempt == 4 { - runtime.GC() + var linkErr *os.LinkError + if errors.As(err, &linkErr) { + var errno syscall.Errno + if errors.As(linkErr.Err, &errno) { + return isRetriableErrno(errno) } } - return errors.Wrapf(err, "failed to rename %s to %s after retries", filepath.Base(src), filepath.Base(dst)) -} -func isRetriableError(err error) bool { if os.IsPermission(err) { return true } @@ -245,40 +341,33 @@ func isRetriableError(err error) bool { if errors.As(err, &pathErr) { var errno syscall.Errno if errors.As(pathErr.Err, &errno) { - // ERROR_ACCESS_DENIED (5) or ERROR_SHARING_VIOLATION (32) - if errno == 5 || errno == 32 { - return true - } + return isRetriableErrno(errno) } } + return false } -func isLinkError(err error) bool { - var linkErr *os.LinkError - return errors.As(err, &linkErr) +func isRetriableErrno(errno syscall.Errno) bool { + return errno == errorAccessDenied || errno == errorSharingViolation } -// removeAll with retries for Windows file locking stability -func (i *Installer) removeAll(path string) error { - var err error - for j := range 5 { - err = os.RemoveAll(path) - if err == nil || os.IsNotExist(err) { - return nil - } - - time.Sleep(100 * time.Millisecond) - - if j == 2 { - // attempt to force GC to release file handles on Windows - runtime.GC() +func isCrossDeviceError(err error) bool { + var linkErr *os.LinkError + if errors.As(err, &linkErr) { + var errno syscall.Errno + if errors.As(linkErr.Err, &errno) { + return errno == syscall.EXDEV || errno == errorNotSameDevice } } - return err + return false } -func (i *Installer) getTargetDir(pkgType types.PackageType, name string) string { +func (i *Installer) getTargetDir(pkgType types.PackageType, name string) (string, error) { + if err := validator.IsValidPackageName(name); err != nil { + return "", errors.Wrapf(err, "refusing to operate on package with invalid name %q", name) + } + subDir := "plugins" switch pkgType { case types.TypeTheme: @@ -286,5 +375,15 @@ func (i *Installer) getTargetDir(pkgType types.PackageType, name string) string case types.TypeMuPlugin: subDir = "mu-plugins" } - return filepath.Join(i.contentDir, subDir, name) + + target := filepath.Join(i.contentDir, subDir, name) + + // IsValidPackageName already prevents escape, but verify the resolved + // path stays inside contentDir in case of symlinks or other weird filesystem setups. + rel, err := filepath.Rel(i.contentDir, target) + if err != nil || !filepath.IsLocal(rel) { + return "", errors.Errorf("package %q resolves outside content directory", name) + } + + return target, nil } diff --git a/pkg/pm/workspace/lock.go b/pkg/pm/workspace/lock.go new file mode 100644 index 00000000..5b13dd3c --- /dev/null +++ b/pkg/pm/workspace/lock.go @@ -0,0 +1,66 @@ +package workspace + +import ( + "context" + "os" + "path/filepath" + "time" + + "github.com/gofrs/flock" + "github.com/pkg/errors" +) + +type ProjectLock struct { + fileLock *flock.Flock +} + +// AcquireLock blocks until the caller can take an exclusive lock on the +// project, or the ctx is cancelled. Any command that reads then mutates +// wpm.json or wpm.lock MUST hold this lock for the entire +// read-modify-write window. +func AcquireLock(ctx context.Context, baseDir string, printWaitMsg func()) (*ProjectLock, error) { + wpmDir := filepath.Join(baseDir, ".wpm") + if err := os.MkdirAll(wpmDir, 0o755); err != nil { + return nil, errors.Wrap(err, "failed to create workspace directory") + } + + _ = os.WriteFile(filepath.Join(wpmDir, ".gitignore"), []byte("*\n"), 0o644) + + fileLock := flock.New(filepath.Join(wpmDir, "install.lock")) + + locked, err := fileLock.TryLock() + if err != nil { + return nil, errors.Wrap(err, "failed to acquire workspace lock") + } + if locked { + return &ProjectLock{fileLock: fileLock}, nil + } + + if printWaitMsg != nil { + printWaitMsg() + } + + locked, err = fileLock.TryLockContext(ctx, 200*time.Millisecond) + if err != nil { + return nil, errors.Wrap(err, "failed while waiting for workspace lock") + } + if !locked { + return nil, errors.Wrap(ctx.Err(), "operation cancelled while waiting for workspace lock") + } + + return &ProjectLock{fileLock: fileLock}, nil +} + +// Release unlocks and closes the underlying file descriptor. Must be +// called exactly once. Safe to call on a nil receiver. +// +// We intentionally do NOT remove the lock file from disk: deleting an +// in-use lock file races against concurrent processes that opened the +// same path before our delete, producing two processes locking different +// inodes for the same path. +func (l *ProjectLock) Release() error { + if l == nil || l.fileLock == nil { + return nil + } + return l.fileLock.Close() +} diff --git a/pkg/pm/wpmjson/validator/validator.go b/pkg/pm/wpmjson/validator/validator.go index 46092daa..54937093 100644 --- a/pkg/pm/wpmjson/validator/validator.go +++ b/pkg/pm/wpmjson/validator/validator.go @@ -3,6 +3,7 @@ package validator import ( "fmt" "net/url" + "path/filepath" "regexp" "strings" "unicode" @@ -30,6 +31,15 @@ func IsValidPackageName(name string) error { return nil } +// IsValidDistTag checks if the dist tag is valid. +func IsValidDistTag(tag string) error { + if len(tag) > 64 { + return fmt.Errorf("must be at most 64 characters") + } + + return IsValidPackageName(tag) +} + // IsValidPackageType checks if the package type is valid. func IsValidPackageType(t types.PackageType) error { if !t.Valid() { @@ -224,3 +234,18 @@ func ValidateRequires(wp, php string) error { } return errs.Err() } + +// IsValidProjectRelPath checks that a path is relative, non-empty, and stays within the project root. +func IsValidProjectRelPath(p string) error { + if p == "" { + return fmt.Errorf("must not be empty") + } + if filepath.IsAbs(p) { + return fmt.Errorf("must be a relative path") + } + cleaned := filepath.Clean(p) + if !filepath.IsLocal(cleaned) { + return fmt.Errorf("must not contain '..' or escape the project directory") + } + return nil +} diff --git a/pkg/pm/wpmjson/wpmjson.go b/pkg/pm/wpmjson/wpmjson.go index fa16006d..09ac7900 100644 --- a/pkg/pm/wpmjson/wpmjson.go +++ b/pkg/pm/wpmjson/wpmjson.go @@ -2,6 +2,7 @@ package wpmjson import ( "encoding/json" + "fmt" "os" "path/filepath" "wpm/pkg/pm" @@ -109,6 +110,33 @@ func (c *Config) Validate() error { errs.MustMerge(validator.ValidateDependencies(*c.DevDependencies, "devDependencies")) } + // Config field validations + if c.Config != nil { + if c.Config.BinDir != "" { + errs.Add("config.bin-dir", validator.IsValidProjectRelPath(c.Config.BinDir)) + } + + if c.Config.ContentDir != "" { + errs.Add("config.content-dir", validator.IsValidProjectRelPath(c.Config.ContentDir)) + } + } + + return errs.Err() +} + +// ValidateDependencyNames checks only the keys of dependencies / devDependencies. +func (c *Config) ValidateDependencyNames() error { + var errs validator.ErrorList + if c.Dependencies != nil { + for name := range *c.Dependencies { + errs.Add(fmt.Sprintf("dependencies[%s]", name), validator.IsValidPackageName(name)) + } + } + if c.DevDependencies != nil { + for name := range *c.DevDependencies { + errs.Add(fmt.Sprintf("devDependencies[%s]", name), validator.IsValidPackageName(name)) + } + } return errs.Err() } diff --git a/pkg/pm/wpmlock/lockfile.go b/pkg/pm/wpmlock/lockfile.go index 6088bc22..e0fbae41 100644 --- a/pkg/pm/wpmlock/lockfile.go +++ b/pkg/pm/wpmlock/lockfile.go @@ -6,6 +6,7 @@ import ( "path/filepath" "wpm/pkg/pm" "wpm/pkg/pm/wpmjson/types" + "wpm/pkg/pm/wpmjson/validator" "github.com/pkg/errors" ) @@ -66,6 +67,12 @@ func Read(cwd string) (*Lockfile, error) { lockfile.Packages = make(map[string]LockPackage) } + for name := range lockfile.Packages { + if err := validator.IsValidPackageName(name); err != nil { + return nil, errors.Wrapf(err, "invalid package name %q in lockfile", name) + } + } + lockfile.Indentation = pm.DetectIndentation(data) return &lockfile, nil