mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-04 08:27:31 +00:00
6487d14855
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 <pkg-spec> <message>` 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 <wxiaoguang@gmail.com> Co-authored-by: bircni <bircni@icloud.com>
504 lines
14 KiB
Go
504 lines
14 KiB
Go
// Copyright 2021 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
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"
|
|
)
|
|
|
|
func TestParsePackage(t *testing.T) {
|
|
packageScope := "@scope"
|
|
packageName := "test-package"
|
|
packageFullName := packageScope + "/" + packageName
|
|
packageVersion := "1.0.1-pre"
|
|
packageTag := "latest"
|
|
packageAuthor := "KN4CK3R"
|
|
packageBin := "gitea"
|
|
packageDescription := "Test Description"
|
|
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)
|
|
assert.Error(t, err)
|
|
})
|
|
|
|
t.Run("InvalidUploadNoData", func(t *testing.T) {
|
|
b, _ := json.Marshal(packageUpload{})
|
|
p, err := ParsePackage(bytes.NewReader(b))
|
|
assert.Nil(t, p)
|
|
assert.ErrorIs(t, err, ErrInvalidPackage)
|
|
})
|
|
|
|
t.Run("InvalidPackageName", func(t *testing.T) {
|
|
test := func(t *testing.T, name string) {
|
|
b, _ := json.Marshal(packageUpload{
|
|
PackageMetadata: PackageMetadata{
|
|
ID: name,
|
|
Name: name,
|
|
Versions: map[string]*PackageMetadataVersion{
|
|
packageVersion: {
|
|
Name: name,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
p, err := ParsePackage(bytes.NewReader(b))
|
|
assert.Nil(t, p)
|
|
assert.ErrorIs(t, err, ErrInvalidPackageName)
|
|
}
|
|
|
|
test(t, " test ")
|
|
test(t, " test")
|
|
test(t, "test ")
|
|
test(t, "te st")
|
|
test(t, "Test")
|
|
test(t, "_test")
|
|
test(t, ".test")
|
|
test(t, "^test")
|
|
test(t, "te^st")
|
|
test(t, "te|st")
|
|
test(t, "te)(st")
|
|
test(t, "te'st")
|
|
test(t, "te!st")
|
|
test(t, "te*st")
|
|
test(t, "te~st")
|
|
test(t, "invalid/scope")
|
|
test(t, "@invalid/_name")
|
|
test(t, "@invalid/.name")
|
|
})
|
|
|
|
t.Run("ValidPackageName", func(t *testing.T) {
|
|
test := func(t *testing.T, name string) {
|
|
b, _ := json.Marshal(packageUpload{
|
|
PackageMetadata: PackageMetadata{
|
|
ID: name,
|
|
Name: name,
|
|
Versions: map[string]*PackageMetadataVersion{
|
|
packageVersion: {
|
|
Name: name,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
p, err := ParsePackage(bytes.NewReader(b))
|
|
assert.Nil(t, p)
|
|
assert.ErrorIs(t, err, ErrInvalidPackageVersion)
|
|
}
|
|
|
|
test(t, "test")
|
|
test(t, "@scope/name")
|
|
test(t, "@scope/q")
|
|
test(t, "q")
|
|
test(t, "@scope/package-name")
|
|
test(t, "@scope/package.name")
|
|
test(t, "@scope/package_name")
|
|
test(t, "123name")
|
|
test(t, "----")
|
|
test(t, packageFullName)
|
|
})
|
|
|
|
t.Run("InvalidPackageVersion", func(t *testing.T) {
|
|
version := "first-version"
|
|
b, _ := json.Marshal(packageUpload{
|
|
PackageMetadata: PackageMetadata{
|
|
ID: packageFullName,
|
|
Name: packageFullName,
|
|
Versions: map[string]*PackageMetadataVersion{
|
|
version: {
|
|
Name: packageFullName,
|
|
Version: version,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
p, err := ParsePackage(bytes.NewReader(b))
|
|
assert.Nil(t, p)
|
|
assert.ErrorIs(t, err, ErrInvalidPackageVersion)
|
|
})
|
|
|
|
t.Run("InvalidAttachment", func(t *testing.T) {
|
|
b, _ := json.Marshal(packageUpload{
|
|
PackageMetadata: PackageMetadata{
|
|
ID: packageFullName,
|
|
Name: packageFullName,
|
|
Versions: map[string]*PackageMetadataVersion{
|
|
packageVersion: {
|
|
Name: packageFullName,
|
|
Version: packageVersion,
|
|
},
|
|
},
|
|
},
|
|
Attachments: map[string]*PackageAttachment{
|
|
"dummy.tgz": {},
|
|
},
|
|
})
|
|
|
|
p, err := ParsePackage(bytes.NewReader(b))
|
|
assert.Nil(t, p)
|
|
assert.ErrorIs(t, err, ErrInvalidAttachment)
|
|
})
|
|
|
|
t.Run("InvalidData", func(t *testing.T) {
|
|
filename := fmt.Sprintf("%s-%s.tgz", packageFullName, packageVersion)
|
|
b, _ := json.Marshal(packageUpload{
|
|
PackageMetadata: PackageMetadata{
|
|
ID: packageFullName,
|
|
Name: packageFullName,
|
|
Versions: map[string]*PackageMetadataVersion{
|
|
packageVersion: {
|
|
Name: packageFullName,
|
|
Version: packageVersion,
|
|
},
|
|
},
|
|
},
|
|
Attachments: map[string]*PackageAttachment{
|
|
filename: {
|
|
Data: "/",
|
|
},
|
|
},
|
|
})
|
|
|
|
p, err := ParsePackage(bytes.NewReader(b))
|
|
assert.Nil(t, p)
|
|
assert.ErrorIs(t, err, ErrInvalidAttachment)
|
|
})
|
|
|
|
t.Run("InvalidIntegrity", func(t *testing.T) {
|
|
filename := fmt.Sprintf("%s-%s.tgz", packageFullName, packageVersion)
|
|
b, _ := json.Marshal(packageUpload{
|
|
PackageMetadata: PackageMetadata{
|
|
ID: packageFullName,
|
|
Name: packageFullName,
|
|
Versions: map[string]*PackageMetadataVersion{
|
|
packageVersion: {
|
|
Name: packageFullName,
|
|
Version: packageVersion,
|
|
Dist: PackageDistribution{
|
|
Integrity: "sha512-test==",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
Attachments: map[string]*PackageAttachment{
|
|
filename: {
|
|
Data: data,
|
|
},
|
|
},
|
|
})
|
|
|
|
p, err := ParsePackage(bytes.NewReader(b))
|
|
assert.Nil(t, p)
|
|
assert.ErrorIs(t, err, ErrInvalidIntegrity)
|
|
})
|
|
|
|
t.Run("InvalidIntegrity2", func(t *testing.T) {
|
|
filename := fmt.Sprintf("%s-%s.tgz", packageFullName, packageVersion)
|
|
b, _ := json.Marshal(packageUpload{
|
|
PackageMetadata: PackageMetadata{
|
|
ID: packageFullName,
|
|
Name: packageFullName,
|
|
Versions: map[string]*PackageMetadataVersion{
|
|
packageVersion: {
|
|
Name: packageFullName,
|
|
Version: packageVersion,
|
|
Dist: PackageDistribution{
|
|
Integrity: integrity,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
Attachments: map[string]*PackageAttachment{
|
|
filename: {
|
|
Data: base64.StdEncoding.EncodeToString([]byte("data")),
|
|
},
|
|
},
|
|
})
|
|
|
|
p, err := ParsePackage(bytes.NewReader(b))
|
|
assert.Nil(t, p)
|
|
assert.ErrorIs(t, err, ErrInvalidIntegrity)
|
|
})
|
|
|
|
t.Run("Valid", func(t *testing.T) {
|
|
filename := fmt.Sprintf("%s-%s.tgz", packageFullName, packageVersion)
|
|
b, _ := json.Marshal(packageUpload{
|
|
PackageMetadata: PackageMetadata{
|
|
ID: packageFullName,
|
|
Name: packageFullName,
|
|
DistTags: map[string]string{
|
|
packageTag: packageVersion,
|
|
},
|
|
Versions: map[string]*PackageMetadataVersion{
|
|
packageVersion: {
|
|
Name: packageFullName,
|
|
Version: packageVersion,
|
|
Description: packageDescription,
|
|
Author: User{Name: packageAuthor},
|
|
License: "MIT",
|
|
Homepage: "https://gitea.io/",
|
|
Readme: packageDescription,
|
|
Dependencies: map[string]string{
|
|
"package": "1.2.0",
|
|
},
|
|
Bin: map[string]string{
|
|
"bin": packageBin,
|
|
},
|
|
Dist: PackageDistribution{
|
|
Integrity: integrity,
|
|
},
|
|
Repository: repository,
|
|
},
|
|
},
|
|
},
|
|
Attachments: map[string]*PackageAttachment{
|
|
filename: {
|
|
Data: data,
|
|
},
|
|
},
|
|
})
|
|
|
|
p, err := ParsePackage(bytes.NewReader(b))
|
|
assert.NotNil(t, p)
|
|
assert.NoError(t, err)
|
|
|
|
assert.Equal(t, packageFullName, p.Name)
|
|
assert.Equal(t, packageVersion, p.Version)
|
|
assert.Equal(t, []string{packageTag}, p.DistTags)
|
|
assert.Equal(t, fmt.Sprintf("%s-%s.tgz", strings.Split(packageFullName, "/")[1], packageVersion), p.Filename)
|
|
b, _ = base64.StdEncoding.DecodeString(data)
|
|
assert.Equal(t, b, p.Data)
|
|
assert.Equal(t, packageName, p.Metadata.Name)
|
|
assert.Equal(t, packageScope, p.Metadata.Scope)
|
|
assert.Equal(t, packageDescription, p.Metadata.Description)
|
|
assert.Equal(t, packageDescription, p.Metadata.Readme)
|
|
assert.Equal(t, packageAuthor, p.Metadata.Author)
|
|
assert.Equal(t, packageBin, p.Metadata.Bin["bin"])
|
|
assert.Equal(t, "MIT", string(p.Metadata.License))
|
|
assert.Equal(t, "https://gitea.io/", p.Metadata.ProjectURL)
|
|
assert.Contains(t, p.Metadata.Dependencies, "package")
|
|
assert.Equal(t, "1.2.0", p.Metadata.Dependencies["package"])
|
|
assert.Equal(t, repository.Type, p.Metadata.Repository.Type)
|
|
assert.Equal(t, repository.URL, p.Metadata.Repository.URL)
|
|
assert.Equal(t, repository.Directory, p.Metadata.Repository.Directory)
|
|
})
|
|
|
|
t.Run("ValidLicenseMap", func(t *testing.T) {
|
|
packageJSON := `{
|
|
"versions": {
|
|
"0.1.1": {
|
|
"name": "dev-null",
|
|
"version": "0.1.1",
|
|
"license": {
|
|
"type": "MIT"
|
|
},
|
|
"dist": {
|
|
"integrity": "sha256-"
|
|
}
|
|
}
|
|
},
|
|
"_attachments": {
|
|
"foo": {
|
|
"data": "AAAA"
|
|
}
|
|
}
|
|
}`
|
|
p, err := ParsePackage(strings.NewReader(packageJSON))
|
|
require.NoError(t, err)
|
|
require.Equal(t, "MIT", string(p.Metadata.License))
|
|
})
|
|
|
|
t.Run("ValidRepositoryAndBinAsString", func(t *testing.T) {
|
|
// npm allows "repository" and "bin" to be plain strings, not only objects.
|
|
packageJSON := `{
|
|
"versions": {
|
|
"0.1.1": {
|
|
"name": "dev-null",
|
|
"version": "0.1.1",
|
|
"bin": "./cli.js",
|
|
"repository": "https://gitea.io/gitea/test.git",
|
|
"dist": {
|
|
"integrity": "sha256-"
|
|
}
|
|
}
|
|
},
|
|
"_attachments": {
|
|
"foo": {
|
|
"data": "AAAA"
|
|
}
|
|
}
|
|
}`
|
|
p, err := ParsePackage(strings.NewReader(packageJSON))
|
|
require.NoError(t, err)
|
|
require.Equal(t, "https://gitea.io/gitea/test.git", p.Metadata.Repository.URL)
|
|
// a string bin is named after the package
|
|
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[:])
|
|
}
|