From 6487d14855f6884480a7f59400fb6d700e6cf9fc Mon Sep 17 00:00:00 2001 From: philip-x-rutkowski-intel-com <104039428+philip-x-rutkowski-intel-com@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:26:08 -0700 Subject: [PATCH] enhance(packages/npm): expand version metadata and support npm deprecate (#37890) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #21624 Adds the npm package version metadata fields that Gitea's npm registry was previously dropping on publish, and implements the `npm deprecate` command, which Gitea did not accept before. ### New / pass-through metadata fields The following are now parsed from the publish payload, persisted in the stored `npm.Metadata`, and re-emitted on the abbreviated version manifest returned to npm clients: - `hasInstallScript` — auto-detected from `scripts.preinstall` / `scripts.install` / `scripts.postinstall` (also honors a client-supplied value). Without this flag, `npm install` skips lifecycle scripts. - `_hasShrinkwrap` — authoritatively derived by inspecting the uploaded tarball for a top-level `*/npm-shrinkwrap.json` entry. Client-supplied values are ignored. Decompression failures fall back to `false` and do not block publish (integrity has already been validated). - `engines` (`map[string]string`) - `cpu`, `os` (`[]string`) - `directories` (`map[string]string`) - `funding` (`any`; preserves the spec's string / object / array shape) - `acceptDependencies` (`map[string]string`) - `deprecated` (`string`) `peerDependenciesMeta` was already in the stored struct but is now exercised by tests. ### `npm deprecate` support `npm deprecate ` PUTs the package document to the same URL as publish but with no `_attachments`. The router now detects that shape and routes to a new handler that updates each affected version's stored `Metadata.Deprecated` via `packages_model.UpdateVersion`. An empty message clears the flag (undeprecate). Unknown versions are silently skipped, matching npm's behavior. No new routes were added. Supported invocations include: - `npm deprecate my-thing@"< 0.2.3" "critical bug fixed in v0.2.3"` - `npm deprecate my-thing@1.x "1.x is no longer supported"` - `npm deprecate my-thing@1.0.0 ""` (undeprecate) --------- Co-authored-by: wxiaoguang Co-authored-by: bircni --- modules/packages/arch/metadata_test.go | 4 +- modules/packages/npm/creator.go | 146 ++++++++++++++++- modules/packages/npm/creator_test.go | 151 ++++++++++++++++- modules/packages/npm/metadata.go | 9 ++ modules/test/utils.go | 22 +-- routers/api/packages/npm/api.go | 9 ++ routers/api/packages/npm/npm.go | 60 ++++++- tests/integration/api_packages_npm_test.go | 180 ++++++++++++++++++++- 8 files changed, 553 insertions(+), 28 deletions(-) diff --git a/modules/packages/arch/metadata_test.go b/modules/packages/arch/metadata_test.go index 3e6897d406..17cb747547 100644 --- a/modules/packages/arch/metadata_test.go +++ b/modules/packages/arch/metadata_test.go @@ -175,14 +175,14 @@ func TestParsePackageInfo(t *testing.T) { // metadata amplification from a package with a huge number of (tiny) file entries. func TestParsePackageTooManyFiles(t *testing.T) { defer test.MockVariableValue(&maxFileEntries, 3)() - buf := test.WriteTarCompression(func(w io.Writer) io.WriteCloser { return gzip.NewWriter(w) }, map[string]string{ + buf := test.WriteTarCompression(gzip.NewWriter, map[string]string{ "file1": "content1", ".PKGINFO": string(createPKGINFOContent(packageName, packageVersion)), }) _, err := ParsePackage(buf) assert.NoError(t, err) - buf = test.WriteTarCompression(func(w io.Writer) io.WriteCloser { return gzip.NewWriter(w) }, map[string]string{ + buf = test.WriteTarCompression(gzip.NewWriter, map[string]string{ "file1": "content1", "file2": "content2", "file3": "content3", diff --git a/modules/packages/npm/creator.go b/modules/packages/npm/creator.go index e419898851..e0a6bde74d 100644 --- a/modules/packages/npm/creator.go +++ b/modules/packages/npm/creator.go @@ -4,7 +4,9 @@ package npm import ( + "archive/tar" "bytes" + "compress/gzip" "crypto/sha1" "crypto/sha512" "encoding/base64" @@ -108,6 +110,15 @@ type PackageMetadataVersion struct { Readme string `json:"readme,omitempty"` Dist PackageDistribution `json:"dist"` Maintainers []User `json:"maintainers,omitempty"` + HasInstallScript bool `json:"hasInstallScript,omitempty"` + HasShrinkwrap bool `json:"_hasShrinkwrap,omitempty"` + Engines map[string]string `json:"engines,omitempty"` + CPU []string `json:"cpu,omitempty"` + OS []string `json:"os,omitempty"` + Directories map[string]string `json:"directories,omitempty"` + Funding any `json:"funding,omitempty"` + AcceptDependencies map[string]string `json:"acceptDependencies,omitempty"` + Deprecated string `json:"deprecated,omitempty"` } // PackageDistribution https://github.com/npm/registry/blob/master/docs/REGISTRY-API.md#version @@ -243,13 +254,38 @@ type packageUpload struct { Attachments map[string]*PackageAttachment `json:"_attachments"` } -// ParsePackage parses the content into a npm package +// ParseUpload decodes a npm PUT body. Exactly one of the returned pointers +// is non-nil on success; a body without `_attachments` is a deprecate request, +// otherwise it is a "publish". +func ParseUpload(r io.Reader) (*Package, *PackageDeprecation, error) { + body, err := io.ReadAll(io.LimitReader(r, 10*1024*1024)) + if err != nil { + return nil, nil, err + } + var upload packageUpload + if err := json.Unmarshal(body, &upload); err != nil { + return nil, nil, err + } + if len(upload.Attachments) == 0 { + dep, err := parseUploadDeprecation(&upload, body) + return nil, dep, err + } + p, err := parseUploadPackage(&upload) + return p, nil, err +} + +// ParsePackage parses a npm publish PUT body. Bodies without `_attachments` +// surface as ErrInvalidAttachment once name/version validation has passed. func ParsePackage(r io.Reader) (*Package, error) { var upload packageUpload if err := json.NewDecoder(r).Decode(&upload); err != nil { return nil, err } + return parseUploadPackage(&upload) +} +// parseUploadPackage builds a Package from a decoded publish body. +func parseUploadPackage(upload *packageUpload) (*Package, error) { for _, meta := range upload.Versions { if !validateName(meta.Name) { return nil, ErrInvalidPackageName @@ -298,6 +334,13 @@ func ParsePackage(r io.Reader) (*Package, error) { Bin: meta.Bin, Readme: meta.Readme, Repository: meta.Repository, + Engines: meta.Engines, + CPU: meta.CPU, + OS: meta.OS, + Directories: meta.Directories, + Funding: meta.Funding, + AcceptDependencies: meta.AcceptDependencies, + Deprecated: meta.Deprecated, }, } @@ -344,12 +387,75 @@ func ParsePackage(r io.Reader) (*Package, error) { return nil, ErrInvalidIntegrity } + // Derive _hasShrinkwrap and hasInstallScript from the tarball; the + // packument can lie about either. + p.Metadata.HasShrinkwrap, p.Metadata.HasInstallScript = inspectTarball(data) + return p, nil } return nil, ErrInvalidPackage } +// maxNpmTarballScanBytes caps the decompressed tarball bytes inspectTarball +// will read; defends against gzip bombs. +const maxNpmTarballScanBytes = int64(32 * 1024 * 1024) // 32 MiB + +// maxNpmPackageJSONBytes caps the package.json bytes decoded from the tarball. +const maxNpmPackageJSONBytes = int64(1 * 1024 * 1024) // 1 MiB + +// inspectTarball reports hasShrinkwrap (presence of package/npm-shrinkwrap.json) +// and hasInstallScript (package/package.json declares any of preinstall, +// install, postinstall). Both must be derived server-side because the client +// can lie in the packument. Any read/decode error yields (false, false) so a +// malformed archive does not block publishing. +func inspectTarball(data []byte) (hasShrinkwrap, hasInstallScript bool) { + gr, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + return false, false + } + defer gr.Close() + + tr := tar.NewReader(io.LimitReader(gr, maxNpmTarballScanBytes)) + for { + hdr, err := tr.Next() + if err != nil { + return hasShrinkwrap, hasInstallScript + } + // npm pack puts files under a single root directory (usually "package/"). + name := strings.TrimPrefix(hdr.Name, "./") + if strings.Count(name, "/") != 1 { + continue + } + switch { + case strings.HasSuffix(name, "/npm-shrinkwrap.json"): + hasShrinkwrap = true + case strings.HasSuffix(name, "/package.json"): + hasInstallScript = tarballDeclaresInstallScript(tr) + } + if hasShrinkwrap && hasInstallScript { + return hasShrinkwrap, hasInstallScript + } + } +} + +// tarballDeclaresInstallScript reports whether a package.json declares any +// of preinstall, install, postinstall. +func tarballDeclaresInstallScript(r io.Reader) bool { + var pkg struct { + Scripts map[string]string `json:"scripts"` + } + if err := json.NewDecoder(io.LimitReader(r, maxNpmPackageJSONBytes)).Decode(&pkg); err != nil { + return false + } + for _, name := range []string{"preinstall", "install", "postinstall"} { + if strings.TrimSpace(pkg.Scripts[name]) != "" { + return true + } + } + return false +} + func validateName(name string) bool { if strings.TrimSpace(name) != name { return false @@ -359,3 +465,41 @@ func validateName(name string) bool { } return nameMatch.MatchString(name) } + +// PackageDeprecation is the result of parsing an npm deprecate request body. +// Versions maps a version string to its deprecation message; an empty message +// means "undeprecate". +type PackageDeprecation struct { + PackageName string + Versions map[string]string +} + +// parseUploadDeprecation builds a PackageDeprecation from a body with no +// `_attachments`. Only versions whose object explicitly contained a +// `deprecated` key are emitted, so a subset PUT cannot silently undeprecate +// versions it omitted. An empty string still means "undeprecate". +func parseUploadDeprecation(upload *packageUpload, body []byte) (*PackageDeprecation, error) { + if !validateName(upload.Name) { + return nil, ErrInvalidPackageName + } + var raw struct { + Versions map[string]map[string]any `json:"versions"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return nil, err + } + d := &PackageDeprecation{ + PackageName: upload.Name, + Versions: make(map[string]string, len(raw.Versions)), + } + for v, meta := range upload.Versions { + if meta == nil { + continue + } + if _, ok := raw.Versions[v]["deprecated"]; !ok { + continue + } + d.Versions[v] = meta.Deprecated + } + return d, nil +} diff --git a/modules/packages/npm/creator_test.go b/modules/packages/npm/creator_test.go index 02e17c2c46..154557512c 100644 --- a/modules/packages/npm/creator_test.go +++ b/modules/packages/npm/creator_test.go @@ -5,12 +5,15 @@ package npm import ( "bytes" + "compress/gzip" + "crypto/sha512" "encoding/base64" "fmt" "strings" "testing" "gitea.dev/modules/json" + "gitea.dev/modules/test" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -25,14 +28,18 @@ func TestParsePackage(t *testing.T) { packageAuthor := "KN4CK3R" packageBin := "gitea" packageDescription := "Test Description" - data := "H4sIAAAAAAAA/ytITM5OTE/VL4DQelnF+XkMVAYGBgZmJiYK2MRBwNDcSIHB2NTMwNDQzMwAqA7IMDUxA9LUdgg2UFpcklgEdAql5kD8ogCnhwio5lJQUMpLzE1VslJQcihOzi9I1S9JLS7RhSYIJR2QgrLUouLM/DyQGkM9Az1D3YIiqExKanFyUWZBCVQ2BKhVwQVJDKwosbQkI78IJO/tZ+LsbRykxFXLNdA+HwWjYBSMgpENACgAbtAACAAA" - integrity := "sha512-yA4FJsVhetynGfOC1jFf79BuS+jrHbm0fhh+aHzCQkOaOBXKf9oBnC4a6DnLLnEsHQDRLYd00cwj8sCXpC+wIg==" repository := Repository{ Type: "gitea", URL: "http://localhost:3000/gitea/test.git", Directory: "packages/test-package", } + dataBytes := buildTarball(map[string]string{ + "package/package.json": `{"name": "@scope/test-package","version": "1.0.1-pre","description": "Test Description","author": "KN4CK3R"}`, + }) + data := base64.StdEncoding.EncodeToString(dataBytes) + integrity := "sha512-" + base64Sha512(dataBytes) + t.Run("InvalidUpload", func(t *testing.T) { p, err := ParsePackage(bytes.NewReader([]byte{0})) assert.Nil(t, p) @@ -354,3 +361,143 @@ func TestParsePackage(t *testing.T) { require.Equal(t, "./cli.js", p.Metadata.Bin["dev-null"]) }) } + +// buildTarball assembles a gzipped tar with the given entries. +func buildTarball(files map[string]string) []byte { + return test.WriteTarCompression(gzip.NewWriter, files).Bytes() +} + +func TestInspectTarball(t *testing.T) { + cases := []struct { + name string + files map[string]string + wantShrinkwrap, wantInstaller bool + }{ + { + name: "empty", + files: map[string]string{}, + }, + { + name: "shrinkwrap only", + files: map[string]string{"package/npm-shrinkwrap.json": "{}"}, + wantShrinkwrap: true, + }, + { + name: "postinstall only", + files: map[string]string{"package/package.json": `{"scripts":{"postinstall":"echo hi"}}`}, + wantInstaller: true, + }, + { + name: "preinstall", + files: map[string]string{"package/package.json": `{"scripts":{"preinstall":"noop"}}`}, + wantInstaller: true, + }, + { + name: "install", + files: map[string]string{"package/package.json": `{"scripts":{"install":"noop"}}`}, + wantInstaller: true, + }, + { + name: "whitespace-only script does not count", + files: map[string]string{"package/package.json": `{"scripts":{"postinstall":" "}}`}, + }, + { + name: "unrelated lifecycle script ignored", + files: map[string]string{"package/package.json": `{"scripts":{"test":"jest"}}`}, + }, + { + name: "both", + files: map[string]string{ + "package/npm-shrinkwrap.json": "{}", + "package/package.json": `{"scripts":{"install":"go"}}`, + }, + wantShrinkwrap: true, + wantInstaller: true, + }, + { + name: "nested shrinkwrap ignored", + files: map[string]string{ + "package/subdir/npm-shrinkwrap.json": "{}", + }, + }, + { + name: "leading ./ prefix stripped", + files: map[string]string{"./package/npm-shrinkwrap.json": "{}"}, + // npm pack sometimes emits "./package/..." entries. + wantShrinkwrap: true, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + data := buildTarball(c.files) + gotShrink, gotInstaller := inspectTarball(data) + assert.Equal(t, c.wantShrinkwrap, gotShrink, "shrinkwrap") + assert.Equal(t, c.wantInstaller, gotInstaller, "installScript") + }) + } + + t.Run("malformed gzip returns false", func(t *testing.T) { + hasShrinkwrap, hasInstaller := inspectTarball([]byte("not a gzip")) + assert.False(t, hasShrinkwrap) + assert.False(t, hasInstaller) + }) + + t.Run("malformed package.json falls through", func(t *testing.T) { + data := buildTarball(map[string]string{"package/package.json": "{ this is not json"}) + _, hasInstaller := inspectTarball(data) + assert.False(t, hasInstaller) + }) +} + +func TestParseUpload(t *testing.T) { + pkg := "@scope/test-package" + + t.Run("dispatches deprecate on missing _attachments", func(t *testing.T) { + body := fmt.Sprintf(`{"name":%q,"versions":{"1.0.0":{"deprecated":"gone"},"1.0.1":{"deprecated":""},"1.0.2":{},"1.0.3":null}}`, pkg) + p, dep, err := ParseUpload(strings.NewReader(body)) + require.NoError(t, err) + assert.Nil(t, p) + require.NotNil(t, dep) + assert.Equal(t, map[string]string{"1.0.0": "gone", "1.0.1": ""}, dep.Versions) + }) + + t.Run("dispatches publish when _attachments present", func(t *testing.T) { + // Reuse a minimal tarball with a package.json. + data := buildTarball(map[string]string{"package/package.json": `{}`}) + integrity := "sha512-" + base64Sha512(data) + body := fmt.Sprintf( + `{"name":%q,"versions":{"1.0.0":{"name":%q,"version":"1.0.0","dist":{"integrity":%q}}},"_attachments":{"x.tgz":{"data":%q}}}`, + pkg, pkg, integrity, base64.StdEncoding.EncodeToString(data), + ) + p, dep, err := ParseUpload(strings.NewReader(body)) + require.NoError(t, err) + assert.Nil(t, dep) + require.NotNil(t, p) + assert.Equal(t, pkg, p.Name) + }) + + t.Run("publish whose readme mentions deprecated is not misrouted", func(t *testing.T) { + // The old fast-path used a substring check for "deprecated"; make sure + // the new dispatch keys off _attachments only. + data := buildTarball(map[string]string{"package/package.json": `{}`}) + integrity := "sha512-" + base64Sha512(data) + body := fmt.Sprintf( + `{"name":%q,"versions":{"1.0.0":{"name":%q,"version":"1.0.0","readme":"this package is deprecated!","dist":{"integrity":%q}}},"_attachments":{"x.tgz":{"data":%q}}}`, + pkg, pkg, integrity, base64.StdEncoding.EncodeToString(data), + ) + p, dep, err := ParseUpload(strings.NewReader(body)) + require.NoError(t, err) + assert.Nil(t, dep) + require.NotNil(t, p) + }) + + t.Run("invalid json errors out", func(t *testing.T) { + _, _, err := ParseUpload(strings.NewReader("not json")) + assert.Error(t, err) + }) +} + +func base64Sha512(data []byte) string { + h := sha512.Sum512(data) + return base64.StdEncoding.EncodeToString(h[:]) +} diff --git a/modules/packages/npm/metadata.go b/modules/packages/npm/metadata.go index e6bbcb1177..e4823be7b2 100644 --- a/modules/packages/npm/metadata.go +++ b/modules/packages/npm/metadata.go @@ -24,4 +24,13 @@ type Metadata struct { Bin map[string]string `json:"bin,omitempty"` Readme string `json:"readme,omitempty"` Repository Repository `json:"repository"` + HasInstallScript bool `json:"has_install_script,omitempty"` + HasShrinkwrap bool `json:"has_shrinkwrap,omitempty"` + Engines map[string]string `json:"engines,omitempty"` + CPU []string `json:"cpu,omitempty"` + OS []string `json:"os,omitempty"` + Directories map[string]string `json:"directories,omitempty"` + Funding any `json:"funding,omitempty"` + AcceptDependencies map[string]string `json:"accept_dependencies,omitempty"` + Deprecated string `json:"deprecated,omitempty"` } diff --git a/modules/test/utils.go b/modules/test/utils.go index aa2dfb702f..b682f0762e 100644 --- a/modules/test/utils.go +++ b/modules/test/utils.go @@ -102,10 +102,6 @@ func ReadAllTarGzContent(r io.Reader) (map[string]string, error) { return content, nil } -func WriteTarArchive(files map[string]string) *bytes.Buffer { - return WriteTarCompression(func(w io.Writer) io.WriteCloser { return util.NopCloser{Writer: w} }, files) -} - func WriteZipArchive(files map[string]string) *bytes.Buffer { buf := &bytes.Buffer{} zw := zip.NewWriter(buf) @@ -117,17 +113,15 @@ func WriteZipArchive(files map[string]string) *bytes.Buffer { return buf } -func WriteTarCompression[F func(io.Writer) io.WriteCloser | func(io.Writer) (io.WriteCloser, error)](compression F, files map[string]string) *bytes.Buffer { - buf := &bytes.Buffer{} - var cw io.WriteCloser - switch compressFunc := any(compression).(type) { - case func(io.Writer) io.WriteCloser: - cw = compressFunc(buf) - case func(io.Writer) (io.WriteCloser, error): - cw, _ = compressFunc(buf) - } - tw := tar.NewWriter(cw) +func WriteTarArchive(files map[string]string) *bytes.Buffer { + return WriteTarCompression(func(w io.Writer) io.WriteCloser { return util.NopCloser{Writer: w} }, files) +} +func WriteTarCompression[WC io.WriteCloser, F func(io.Writer) WC](compression F, files map[string]string) *bytes.Buffer { + // TODO: to support xz.NewWriter which returns (*Writer, error), it needs a separate function due to the limit of Golang generic + buf := &bytes.Buffer{} + cw := compression(buf) + tw := tar.NewWriter(cw) for name, content := range files { hdr := &tar.Header{ Name: name, diff --git a/routers/api/packages/npm/api.go b/routers/api/packages/npm/api.go index ffde98b3fa..7fa86c8683 100644 --- a/routers/api/packages/npm/api.go +++ b/routers/api/packages/npm/api.go @@ -71,6 +71,15 @@ func createPackageMetadataVersion(registryURL string, pd *packages_model.Package OptionalDependencies: metadata.OptionalDependencies, Readme: metadata.Readme, Bin: metadata.Bin, + HasInstallScript: metadata.HasInstallScript, + HasShrinkwrap: metadata.HasShrinkwrap, + Engines: metadata.Engines, + CPU: metadata.CPU, + OS: metadata.OS, + Directories: metadata.Directories, + Funding: metadata.Funding, + AcceptDependencies: metadata.AcceptDependencies, + Deprecated: metadata.Deprecated, Dist: npm_module.PackageDistribution{ Shasum: pd.Files[0].Blob.HashSHA1, Integrity: "sha512-" + base64.StdEncoding.EncodeToString(hashBytes), diff --git a/routers/api/packages/npm/npm.go b/routers/api/packages/npm/npm.go index 8aef046b62..e2b4474bc0 100644 --- a/routers/api/packages/npm/npm.go +++ b/routers/api/packages/npm/npm.go @@ -17,6 +17,7 @@ import ( access_model "gitea.dev/models/perm/access" repo_model "gitea.dev/models/repo" "gitea.dev/models/unit" + "gitea.dev/modules/json" "gitea.dev/modules/optional" packages_module "gitea.dev/modules/packages" npm_module "gitea.dev/modules/packages/npm" @@ -154,7 +155,7 @@ func DownloadPackageFileByName(ctx *context.Context) { // UploadPackage creates a new package func UploadPackage(ctx *context.Context) { - npmPackage, err := npm_module.ParsePackage(ctx.Req.Body) + npmPackage, deprecation, err := npm_module.ParseUpload(ctx.Req.Body) if err != nil { if errors.Is(err, util.ErrInvalidArgument) { apiError(ctx, http.StatusBadRequest, err) @@ -164,6 +165,12 @@ func UploadPackage(ctx *context.Context) { return } + // `npm deprecate` reuses the publish endpoint with no `_attachments`. + if deprecation != nil { + deprecatePackage(ctx, deprecation) + return + } + repo, err := repo_model.GetRepositoryByURLRelax(ctx, npmPackage.Metadata.Repository.URL) if err == nil { canWrite := repo.OwnerID == ctx.Doer.ID @@ -252,6 +259,57 @@ func DeletePreview(ctx *context.Context) { ctx.Status(http.StatusOK) } +// deprecatePackage handles an `npm deprecate` request, which is a PUT to the +// package URL with no attachments and a `deprecated` string set on each +// affected version (empty string means undeprecate). +func deprecatePackage(ctx *context.Context, dep *npm_module.PackageDeprecation) { + if len(dep.Versions) == 0 { + apiError(ctx, http.StatusBadRequest, "npm deprecate request contains no versions") + return + } + + // Run per-version updates in one transaction so a partial failure does + // not leave the package in a half-applied state. + err := db.WithTx(ctx, func(txCtx std_ctx.Context) error { + for version, message := range dep.Versions { + pv, err := packages_model.GetVersionByNameAndVersion(txCtx, ctx.Package.Owner.ID, packages_model.TypeNpm, dep.PackageName, version) + if err != nil { + if errors.Is(err, packages_model.ErrPackageNotExist) { + continue + } + return err + } + + metadata := &npm_module.Metadata{} + if err := json.Unmarshal([]byte(pv.MetadataJSON), metadata); err != nil { + return err + } + + if metadata.Deprecated == message { + continue + } + metadata.Deprecated = message + + raw, err := json.Marshal(metadata) + if err != nil { + return err + } + pv.MetadataJSON = string(raw) + + if err := packages_model.UpdateVersion(txCtx, pv); err != nil { + return err + } + } + return nil + }) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + ctx.Status(http.StatusOK) +} + // DeletePackageVersion deletes the package version func DeletePackageVersion(ctx *context.Context) { packageName := packageNameFromParams(ctx) diff --git a/tests/integration/api_packages_npm_test.go b/tests/integration/api_packages_npm_test.go index 9acfef20c6..ab8671a095 100644 --- a/tests/integration/api_packages_npm_test.go +++ b/tests/integration/api_packages_npm_test.go @@ -4,7 +4,11 @@ package integration import ( + "compress/gzip" + "crypto/sha1" + "crypto/sha512" "encoding/base64" + "encoding/hex" "fmt" "net/http" "net/url" @@ -18,6 +22,7 @@ import ( user_model "gitea.dev/models/user" "gitea.dev/modules/packages/npm" "gitea.dev/modules/setting" + "gitea.dev/modules/test" "gitea.dev/tests" "github.com/stretchr/testify/assert" @@ -43,7 +48,14 @@ func TestPackageNpm(t *testing.T) { repoURL := "http://localhost:3000/gitea/test.git" repoDirectory := "package-subdir" - data := "H4sIAAAAAAAA/ytITM5OTE/VL4DQelnF+XkMVAYGBgZmJiYK2MRBwNDcSIHB2NTMwNDQzMwAqA7IMDUxA9LUdgg2UFpcklgEdAql5kD8ogCnhwio5lJQUMpLzE1VslJQcihOzi9I1S9JLS7RhSYIJR2QgrLUouLM/DyQGkM9Az1D3YIiqExKanFyUWZBCVQ2BKhVwQVJDKwosbQkI78IJO/tZ+LsbRykxFXLNdA+HwWjYBSMgpENACgAbtAACAAA" + attachmentBytes := test.WriteTarCompression(gzip.NewWriter, map[string]string{ + "package/package.json": `{"name":"` + packageName + `","version":"` + packageVersion + `","scripts":{"postinstall":"echo hi"}}`, + }).Bytes() + attachmentData := base64.StdEncoding.EncodeToString(attachmentBytes) + sha1Sum := sha1.Sum(attachmentBytes) + sha1SumHex := hex.EncodeToString(sha1Sum[:]) + sha512Sum := sha512.Sum512(attachmentBytes) + integrity := "sha512-" + base64.StdEncoding.EncodeToString(sha512Sum[:]) buildUpload := func(version string) string { return `{ @@ -65,8 +77,8 @@ func TestPackageNpm(t *testing.T) { "` + packageBinName + `": "` + packageBinPath + `" }, "dist": { - "integrity": "sha512-yA4FJsVhetynGfOC1jFf79BuS+jrHbm0fhh+aHzCQkOaOBXKf9oBnC4a6DnLLnEsHQDRLYd00cwj8sCXpC+wIg==", - "shasum": "aaa7eaf852a948b0aa05afeda35b1badca155d90" + "integrity": "` + integrity + `", + "shasum": "` + sha1SumHex + `" }, "repository": { "type": "` + repoType + `", @@ -82,12 +94,29 @@ func TestPackageNpm(t *testing.T) { "soy-milk": { "optional": true } + }, + "scripts": { + "postinstall": "echo hi" + }, + "engines": { + "node": ">=22.7.0", + "npm": ">=10.8.2" + }, + "cpu": ["x64", "arm64"], + "os": ["linux", "darwin"], + "directories": { + "doc": "./doc", + "man": "./man" + }, + "funding": "https://example.com/fund", + "acceptDependencies": { + "left-pad": "1.x" } } }, "_attachments": { "` + packageName + `-` + version + `.tgz": { - "data": "` + data + `" + "data": "` + attachmentData + `" } } }` @@ -126,7 +155,7 @@ func TestPackageNpm(t *testing.T) { pb, err := packages.GetBlobByID(t.Context(), pfs[0].BlobID) assert.NoError(t, err) - assert.Equal(t, int64(192), pb.Size) + assert.EqualValues(t, len(attachmentBytes), pb.Size) }) t.Run("UploadExists", func(t *testing.T) { @@ -144,7 +173,7 @@ func TestPackageNpm(t *testing.T) { AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) - b, _ := base64.StdEncoding.DecodeString(data) + b, _ := base64.StdEncoding.DecodeString(attachmentData) assert.Equal(t, b, resp.Body.Bytes()) req = NewRequest(t, "GET", fmt.Sprintf("%s/-/%s", root, filename)). @@ -185,13 +214,22 @@ func TestPackageNpm(t *testing.T) { assert.Equal(t, packageDescription, pmv.Description) assert.Equal(t, packageAuthor, pmv.Author.Name) assert.Equal(t, packageBinPath, pmv.Bin[packageBinName]) - assert.Equal(t, "sha512-yA4FJsVhetynGfOC1jFf79BuS+jrHbm0fhh+aHzCQkOaOBXKf9oBnC4a6DnLLnEsHQDRLYd00cwj8sCXpC+wIg==", pmv.Dist.Integrity) - assert.Equal(t, "aaa7eaf852a948b0aa05afeda35b1badca155d90", pmv.Dist.Shasum) + assert.Equal(t, integrity, pmv.Dist.Integrity) + assert.Equal(t, sha1SumHex, pmv.Dist.Shasum) assert.Equal(t, fmt.Sprintf("%s%s/-/%s/%s", setting.AppURL, root[1:], packageVersion, filename), pmv.Dist.Tarball) assert.Equal(t, repoType, result.Repository.Type) assert.Equal(t, repoURL, result.Repository.URL) assert.Equal(t, map[string]string{"tea": "2.x", "soy-milk": "1.2"}, pmv.PeerDependencies) assert.Equal(t, map[string]any{"soy-milk": map[string]any{"optional": true}}, pmv.PeerDependenciesMeta) + assert.True(t, pmv.HasInstallScript) + assert.False(t, pmv.HasShrinkwrap) + assert.Equal(t, map[string]string{"node": ">=22.7.0", "npm": ">=10.8.2"}, pmv.Engines) + assert.Equal(t, []string{"x64", "arm64"}, pmv.CPU) + assert.Equal(t, []string{"linux", "darwin"}, pmv.OS) + assert.Equal(t, map[string]string{"doc": "./doc", "man": "./man"}, pmv.Directories) + assert.Equal(t, "https://example.com/fund", pmv.Funding) + assert.Equal(t, map[string]string{"left-pad": "1.x"}, pmv.AcceptDependencies) + assert.Empty(t, pmv.Deprecated) }) t.Run("AddTag", func(t *testing.T) { @@ -315,6 +353,132 @@ func TestPackageNpm(t *testing.T) { `, rendered) }) + t.Run("Deprecate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + deprecationMessage := "critical bug fixed in v0.2.3" + buildDeprecate := func(message string) string { + return `{ + "_id": "` + packageName + `", + "name": "` + packageName + `", + "versions": { + "` + packageVersion + `": { + "name": "` + packageName + `", + "version": "` + packageVersion + `", + "deprecated": "` + message + `" + } + } + }` + } + + req := NewRequestWithBody(t, "PUT", root, strings.NewReader(buildDeprecate(deprecationMessage))). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "GET", root).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + result := DecodeJSON(t, resp, &npm.PackageMetadata{}) + + assert.Contains(t, result.Versions, packageVersion) + assert.Equal(t, deprecationMessage, result.Versions[packageVersion].Deprecated) + + // Empty deprecation message clears the flag (undeprecate). + req = NewRequestWithBody(t, "PUT", root, strings.NewReader(buildDeprecate(""))). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "GET", root).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + result = DecodeJSON(t, resp, &npm.PackageMetadata{}) + assert.Empty(t, result.Versions[packageVersion].Deprecated) + + // Unknown versions are silently skipped (idempotent); the known + // version is still updated. + mixedBody := `{ + "_id": "` + packageName + `", + "name": "` + packageName + `", + "versions": { + "` + packageVersion + `": { + "name": "` + packageName + `", + "version": "` + packageVersion + `", + "deprecated": "` + deprecationMessage + `" + }, + "99.99.99": { + "name": "` + packageName + `", + "version": "99.99.99", + "deprecated": "ghost" + } + } + }` + req = NewRequestWithBody(t, "PUT", root, strings.NewReader(mixedBody)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "GET", root).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + result = DecodeJSON(t, resp, &npm.PackageMetadata{}) + assert.Equal(t, deprecationMessage, result.Versions[packageVersion].Deprecated) + assert.NotContains(t, result.Versions, "99.99.99") + + // Restore the cleared state for subsequent subtests. + req = NewRequestWithBody(t, "PUT", root, strings.NewReader(buildDeprecate(""))). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + // A user who has no write access to the URL owner's packages must + // be rejected by reqPackageAccess before deprecatePackage runs. + otherUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + otherToken := "Bearer " + getTokenForLoggedInUser(t, loginUser(t, otherUser.Name), auth_model.AccessTokenScopeWritePackage) + req = NewRequestWithBody(t, "PUT", root, strings.NewReader(buildDeprecate(deprecationMessage))). + AddTokenAuth(otherToken) + MakeRequest(t, req, http.StatusUnauthorized) + }) + + t.Run("UploadHasShrinkwrapClaim", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // The tarball in `data` does not contain npm-shrinkwrap.json. + // Even when the client claims _hasShrinkwrap: true, the + // authoritative tarball scan must overrule the claim. + claimPackageName := "@scope/test-shrinkwrap-claim" + claimVersion := "1.0.0" + claimRoot := fmt.Sprintf("/api/packages/%s/npm/%s", user.Name, url.QueryEscape(claimPackageName)) + body := `{ + "_id": "` + claimPackageName + `", + "name": "` + claimPackageName + `", + "versions": { + "` + claimVersion + `": { + "name": "` + claimPackageName + `", + "version": "` + claimVersion + `", + "_hasShrinkwrap": true, + "dist": { + "integrity": "` + integrity + `", + "shasum": "` + sha1SumHex + `" + } + } + }, + "_attachments": { + "` + claimPackageName + `-` + claimVersion + `.tgz": { + "data": "` + attachmentData + `" + } + } + }` + req := NewRequestWithBody(t, "PUT", claimRoot, strings.NewReader(body)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + req = NewRequest(t, "GET", claimRoot).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + result := DecodeJSON(t, resp, &npm.PackageMetadata{}) + require.Contains(t, result.Versions, claimVersion) + assert.False(t, result.Versions[claimVersion].HasShrinkwrap, "client-claimed _hasShrinkwrap must be overridden by tarball scan") + + // Clean up so the subsequent Delete subtest's version counts match. + req = NewRequest(t, "DELETE", claimRoot+"/-rev/dummy").AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + }) + t.Run("Delete", func(t *testing.T) { defer tests.PrintCurrentTest(t)()