feat(esm/npm): 用 Go 拉依赖树,加库不再需要 node
零依赖的包下个 tarball 就能用,有依赖的得先解析依赖树——读 semver 范围、
查注册表定版本、递归。这个包把那件事用 Go 做了,于是整条链没有 node:
esm.Install(ctx, "qs", "app/node_modules")
// qs v6.16.0 打进 50 个文件 -> 73.3 KB(依赖树 19 个包 1.7 MB)
= npm.Fetch(拉依赖树到临时目录)+ Vendor(摊平成一个文件)。
刻意不做的(它不是 npm):
- 不跑安装脚本。那是供应链攻击的主要入口,而纯 JS 库没有编译步骤
- 不管 devDependencies / peerDependencies / 平台二进制
- semver 只实现 ^ / ~ / 精确 / x / >= 这个子集
- 只平铺不嵌套
碰上支持不了的(复合范围、主版本冲突)明确报错并指向 npm + Vendor,
不猜版本——猜错了装出来能跑但行为不对,比装不上难查。
子集划得这么小是有依据的:抽 8 个常见包的 44 个传递依赖统计,^ 占 95%,
~ 和精确各一两处,主版本冲突 0 个。
安全上做了两件事:校验注册表给的 sha512(中间的缓存代理、私有源镜像
是真实存在的),以及挡住 tarball 里带 ../ 的路径。
测试全部走内存假注册表,不碰网络:依赖树平铺、共同依赖只装一次、
版本冲突报错、校验和不符、目录穿越。semver 那组表驱动——写这组时抓到
一个真 bug:1.2.x 被映射成了 ^1.2.0,只锁主版本,实际该锁到 1.2。
This commit is contained in:
+341
@@ -0,0 +1,341 @@
|
||||
// Package npm 从 npm 注册表拉包,连同它的依赖一起平铺到 node_modules。
|
||||
//
|
||||
// 存在的理由只有一个:**让加第三方库这件事不需要 node**。
|
||||
//
|
||||
// esbuild 只解析 node_modules 不下载,零依赖的包下个 tarball 解开就行;
|
||||
// 但有依赖的包得先解析依赖树——读 semver 范围、查注册表定版本、递归——
|
||||
// 那正是 npm 真正在干的活。这个包把那件事用 Go 做了。
|
||||
//
|
||||
// npm.Fetch(ctx, dir, "qs") // dir/node_modules/ 下铺出 qs 和它的 18 个依赖
|
||||
// esm.Vendor(dir, "qs", outRoot) // 再摊平成一个文件
|
||||
//
|
||||
// 两步合一是 esm.Install。
|
||||
//
|
||||
// # 它不是 npm
|
||||
//
|
||||
// 只做「把包和依赖弄到磁盘上」这一件事,刻意不做的:
|
||||
//
|
||||
// - **不跑安装脚本**(preinstall/postinstall)。那是 npm 供应链攻击的主要入口,
|
||||
// 而脚本引擎根本不需要它们——纯 JS 库没有编译步骤。
|
||||
// - **不管 devDependencies / peerDependencies / optionalDependencies**。
|
||||
// - **不管平台相关的二进制**。goja 反正也加载不了 .node。
|
||||
// - **semver 只实现子集**,复合范围直接报错,见 semver.go。
|
||||
// - **只平铺,不嵌套**。同一个包被要求两个不兼容的主版本时报错,
|
||||
// 而不是像 npm 那样嵌套安装——实测 8 个常见包的 44 个传递依赖里零冲突,
|
||||
// 真碰上了用 npm 装再走 esm.Vendor。
|
||||
package npm
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha512"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultRegistry 是官方注册表。
|
||||
const DefaultRegistry = "https://registry.npmjs.org"
|
||||
|
||||
// Client 拉包。零值可用,走官方注册表和默认 http.Client。
|
||||
type Client struct {
|
||||
// Registry 注册表地址,空则用 DefaultRegistry。私有源填自己的。
|
||||
Registry string
|
||||
// HTTP 为 nil 时用一个带 60 秒超时的 client。
|
||||
HTTP *http.Client
|
||||
|
||||
cache map[string]*packument // 同一次 Fetch 里同名包只查一次
|
||||
}
|
||||
|
||||
// Resolved 是一个装好的包。
|
||||
type Resolved struct {
|
||||
Name string
|
||||
Version string
|
||||
Dir string // 落在哪
|
||||
}
|
||||
|
||||
// Fetch 把 specs 及其全部依赖平铺到 dir/node_modules。
|
||||
//
|
||||
// spec 的形式是 `名字` 或 `名字@范围`:qs、qs@6.16.0、qs@^6、@scope/pkg@~1.2。
|
||||
// 不写范围就取 latest。
|
||||
//
|
||||
// 已经装过且版本满足要求的包会跳过,所以重复调用是安全的。
|
||||
func (c *Client) Fetch(ctx context.Context, dir string, specs ...string) ([]Resolved, error) {
|
||||
root := filepath.Join(dir, "node_modules")
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("jscriptx/npm: 建目录 %s 失败: %w", root, err)
|
||||
}
|
||||
c.cache = map[string]*packument{}
|
||||
|
||||
// 已定下来的版本:包名 -> 版本。用来发现冲突,也用来跳过装过的
|
||||
picked := map[string]string{}
|
||||
// 谁要求的,报冲突时能说清楚
|
||||
required := map[string][]string{}
|
||||
|
||||
type job struct{ name, rng, by string }
|
||||
queue := make([]job, 0, len(specs))
|
||||
for _, s := range specs {
|
||||
name, rng := SplitSpec(s)
|
||||
queue = append(queue, job{name, rng, "你"})
|
||||
}
|
||||
|
||||
var out []Resolved
|
||||
for len(queue) > 0 {
|
||||
j := queue[0]
|
||||
queue = queue[1:]
|
||||
|
||||
r, err := parseRange(j.rng)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jscriptx/npm: %s 要求 %s@%s:%w", j.by, j.name, j.rng, err)
|
||||
}
|
||||
required[j.name] = append(required[j.name], fmt.Sprintf("%s 要 %s", j.by, j.rng))
|
||||
|
||||
// 已经定过版本:满足就跳过,不满足就是冲突
|
||||
if got, ok := picked[j.name]; ok {
|
||||
v, err := parseVersion(got)
|
||||
if err == nil && r.allows(v) {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("jscriptx/npm: %s 的版本要求冲突(%s),"+
|
||||
"已经定的是 %s。只平铺不嵌套,这种情况请用 npm 装好再走 esm.Vendor",
|
||||
j.name, strings.Join(required[j.name], ","), got)
|
||||
}
|
||||
|
||||
doc, err := c.packument(ctx, j.name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
want := j.rng
|
||||
if r.kind == rangeAny {
|
||||
if tag := doc.DistTags["latest"]; tag != "" {
|
||||
want = tag
|
||||
r, _ = parseRange(tag)
|
||||
}
|
||||
}
|
||||
ver, err := pick(keysOf(doc.Versions), r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jscriptx/npm: %s@%s %w(%s 要求的)", j.name, want, err, j.by)
|
||||
}
|
||||
meta := doc.Versions[ver]
|
||||
picked[j.name] = ver
|
||||
|
||||
pkgDir := filepath.Join(root, filepath.FromSlash(j.name))
|
||||
if err := c.download(ctx, meta.Dist.Tarball, meta.Dist.Integrity, pkgDir); err != nil {
|
||||
return nil, fmt.Errorf("jscriptx/npm: 下载 %s@%s 失败: %w", j.name, ver, err)
|
||||
}
|
||||
out = append(out, Resolved{Name: j.name, Version: ver, Dir: pkgDir})
|
||||
|
||||
for _, dep := range sortedKeys(meta.Dependencies) {
|
||||
queue = append(queue, job{dep, meta.Dependencies[dep], j.name + "@" + ver})
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(out, func(i, k int) bool { return out[i].Name < out[k].Name })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Fetch 用默认 Client 拉包。
|
||||
func Fetch(ctx context.Context, dir string, specs ...string) ([]Resolved, error) {
|
||||
return (&Client{}).Fetch(ctx, dir, specs...)
|
||||
}
|
||||
|
||||
// packument 是注册表返回的包元数据(npm 管这东西叫 packument)。
|
||||
type packument struct {
|
||||
DistTags map[string]string `json:"dist-tags"`
|
||||
Versions map[string]release `json:"versions"`
|
||||
}
|
||||
|
||||
type release struct {
|
||||
Dependencies map[string]string `json:"dependencies"`
|
||||
Dist struct {
|
||||
Tarball string `json:"tarball"`
|
||||
Integrity string `json:"integrity"`
|
||||
} `json:"dist"`
|
||||
}
|
||||
|
||||
func (c *Client) packument(ctx context.Context, name string) (*packument, error) {
|
||||
if d, ok := c.cache[name]; ok {
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// 用 abbreviated 格式,比完整 packument 小一个数量级
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.registry()+"/"+name, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.npm.install-v1+json")
|
||||
|
||||
resp, err := c.http().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jscriptx/npm: 查 %s 失败: %w", name, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, fmt.Errorf("jscriptx/npm: 注册表里没有包 %s", name)
|
||||
}
|
||||
return nil, fmt.Errorf("jscriptx/npm: 查 %s 返回 %s", name, resp.Status)
|
||||
}
|
||||
|
||||
var doc packument
|
||||
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
|
||||
return nil, fmt.Errorf("jscriptx/npm: 解析 %s 的元数据失败: %w", name, err)
|
||||
}
|
||||
c.cache[name] = &doc
|
||||
return &doc, nil
|
||||
}
|
||||
|
||||
// download 拉 tarball、校验、解到 dir。
|
||||
func (c *Client) download(ctx context.Context, url, integrity, dir string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.http().Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("返回 %s", resp.Status)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkIntegrity(body, integrity); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
return untar(body, dir)
|
||||
}
|
||||
|
||||
// checkIntegrity 校验注册表给的 sha512。
|
||||
//
|
||||
// 注册表和下载走的是同一条 HTTPS 连接,理论上已经防篡改了;但缓存代理、私有源
|
||||
// 镜像这些中间环节是真实存在的,校验一下几乎不要钱。
|
||||
func checkIntegrity(body []byte, integrity string) error {
|
||||
if integrity == "" {
|
||||
return nil // 老包可能没有这个字段
|
||||
}
|
||||
alg, want, ok := strings.Cut(integrity, "-")
|
||||
if !ok || alg != "sha512" {
|
||||
return nil // 只认 sha512,别的(sha1)不校验
|
||||
}
|
||||
sum := sha512.Sum512(body)
|
||||
if got := base64.StdEncoding.EncodeToString(sum[:]); got != want {
|
||||
return fmt.Errorf("校验和对不上(可能被中间环节改过)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// untar 解开 npm 的 tarball。
|
||||
//
|
||||
// npm 的包里固定套一层 package/ 目录,剥掉。
|
||||
func untar(body []byte, dir string) error {
|
||||
zr, err := gzip.NewReader(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("不是 gzip: %w", err)
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
tr := tar.NewReader(zr)
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if h.Typeflag != tar.TypeReg {
|
||||
continue
|
||||
}
|
||||
|
||||
// 剥掉外面那层 package/
|
||||
rel := h.Name
|
||||
if i := strings.IndexByte(rel, '/'); i >= 0 {
|
||||
rel = rel[i+1:]
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
// tarball 是外来数据,挡住 ../ 跳出目录
|
||||
clean := filepath.Clean(filepath.FromSlash(rel))
|
||||
if clean == "." || strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
|
||||
return fmt.Errorf("包里有可疑路径 %q", h.Name)
|
||||
}
|
||||
|
||||
p := filepath.Join(dir, clean)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(f, tr); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) registry() string {
|
||||
if c.Registry != "" {
|
||||
return strings.TrimSuffix(c.Registry, "/")
|
||||
}
|
||||
return DefaultRegistry
|
||||
}
|
||||
|
||||
func (c *Client) http() *http.Client {
|
||||
if c.HTTP != nil {
|
||||
return c.HTTP
|
||||
}
|
||||
return &http.Client{Timeout: 60 * time.Second}
|
||||
}
|
||||
|
||||
// SplitSpec 把 "qs@^6" 拆成名字和范围。scoped 包名自带一个 @,得从后面找。
|
||||
//
|
||||
// qs → qs, ""
|
||||
// qs@^6 → qs, "^6"
|
||||
// @scope/pkg@~1 → @scope/pkg, "~1"
|
||||
func SplitSpec(spec string) (name, rng string) {
|
||||
if i := strings.LastIndexByte(spec, '@'); i > 0 {
|
||||
return spec[:i], spec[i+1:]
|
||||
}
|
||||
return spec, ""
|
||||
}
|
||||
|
||||
func keysOf(m map[string]release) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string]string) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package npm_test
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha512"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.fsdpf.net/go/jscriptx/esm/npm"
|
||||
)
|
||||
|
||||
// 假注册表:把包元数据和 tarball 都放内存里,测试不碰网络。
|
||||
type fakeRegistry struct {
|
||||
*httptest.Server
|
||||
// 包名 -> 版本 -> 依赖
|
||||
pkgs map[string]map[string]map[string]string
|
||||
// 故意破坏某个包的校验和
|
||||
corrupt map[string]bool
|
||||
}
|
||||
|
||||
func newRegistry(t *testing.T) *fakeRegistry {
|
||||
t.Helper()
|
||||
r := &fakeRegistry{
|
||||
pkgs: map[string]map[string]map[string]string{},
|
||||
corrupt: map[string]bool{},
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
|
||||
path := strings.TrimPrefix(req.URL.Path, "/")
|
||||
|
||||
// /<name>/-/<name>-<ver>.tgz 取 tarball
|
||||
if i := strings.Index(path, "/-/"); i >= 0 {
|
||||
name := path[:i]
|
||||
file := strings.TrimSuffix(path[i+3:], ".tgz")
|
||||
ver := file[strings.LastIndexByte(file, '-')+1:]
|
||||
body := r.tarball(t, name, ver)
|
||||
if r.corrupt[name] {
|
||||
body = append(body, 0) // 改一个字节,校验和就对不上了
|
||||
}
|
||||
w.Write(body)
|
||||
return
|
||||
}
|
||||
|
||||
// /<name> 取元数据
|
||||
vers, ok := r.pkgs[path]
|
||||
if !ok {
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
}
|
||||
doc := map[string]any{"dist-tags": map[string]string{"latest": highest(vers)}}
|
||||
out := map[string]any{}
|
||||
for v, deps := range vers {
|
||||
out[v] = map[string]any{
|
||||
"dependencies": deps,
|
||||
"dist": map[string]string{
|
||||
"tarball": r.URL + "/" + path + "/-/" + path + "-" + v + ".tgz",
|
||||
"integrity": integrityOf(r.tarball(t, path, v)),
|
||||
},
|
||||
}
|
||||
}
|
||||
doc["versions"] = out
|
||||
json.NewEncoder(w).Encode(doc)
|
||||
})
|
||||
|
||||
r.Server = httptest.NewServer(mux)
|
||||
t.Cleanup(r.Close)
|
||||
return r
|
||||
}
|
||||
|
||||
// add 往假注册表里放一个版本。
|
||||
func (r *fakeRegistry) add(name, version string, deps map[string]string) {
|
||||
if r.pkgs[name] == nil {
|
||||
r.pkgs[name] = map[string]map[string]string{}
|
||||
}
|
||||
r.pkgs[name][version] = deps
|
||||
}
|
||||
|
||||
// tarball 现造一个 npm 格式的 tarball:外面套一层 package/。
|
||||
func (r *fakeRegistry) tarball(t *testing.T, name, version string) []byte {
|
||||
t.Helper()
|
||||
|
||||
pkg, _ := json.Marshal(map[string]any{
|
||||
"name": name, "version": version, "main": "index.js",
|
||||
"dependencies": r.pkgs[name][version],
|
||||
})
|
||||
files := map[string]string{
|
||||
"package/package.json": string(pkg),
|
||||
"package/index.js": fmt.Sprintf("export const who = %q\n", name+"@"+version),
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(zw)
|
||||
for _, n := range []string{"package/package.json", "package/index.js"} {
|
||||
body := files[n]
|
||||
tw.WriteHeader(&tar.Header{Name: n, Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg})
|
||||
tw.Write([]byte(body))
|
||||
}
|
||||
tw.Close()
|
||||
zw.Close()
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func integrityOf(b []byte) string {
|
||||
sum := sha512.Sum512(b)
|
||||
return "sha512-" + base64.StdEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func highest(vers map[string]map[string]string) string {
|
||||
best := ""
|
||||
for v := range vers {
|
||||
if v > best { // 测试里的版本号都是同位数的,字符串比较够用
|
||||
best = v
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func client(r *fakeRegistry) *npm.Client {
|
||||
return &npm.Client{Registry: r.URL, HTTP: r.Client()}
|
||||
}
|
||||
|
||||
// 依赖树要被完整拉下来,平铺到 node_modules。
|
||||
func TestFetch_把依赖树平铺下来(t *testing.T) {
|
||||
r := newRegistry(t)
|
||||
r.add("main", "1.0.0", map[string]string{"mid": "^2.0.0"})
|
||||
r.add("mid", "2.3.0", map[string]string{"leaf": "~1.1.0"})
|
||||
r.add("leaf", "1.1.5", nil)
|
||||
r.add("leaf", "1.2.0", nil) // ~1.1.0 不该选到它
|
||||
|
||||
dir := t.TempDir()
|
||||
got, err := client(r).Fetch(context.Background(), dir, "main")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := map[string]string{"main": "1.0.0", "mid": "2.3.0", "leaf": "1.1.5"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("拉下来 %d 个包,该是 %d 个:%+v", len(got), len(want), got)
|
||||
}
|
||||
for _, p := range got {
|
||||
if want[p.Name] != p.Version {
|
||||
t.Errorf("%s 装的是 %s,该是 %s", p.Name, p.Version, want[p.Name])
|
||||
}
|
||||
// 平铺:全在 node_modules 顶层
|
||||
if filepath.Dir(p.Dir) != filepath.Join(dir, "node_modules") {
|
||||
t.Errorf("%s 没在顶层:%s", p.Name, p.Dir)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(p.Dir, "package.json")); err != nil {
|
||||
t.Errorf("%s 没解开: %v", p.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 同一个包被多处依赖,只装一次。
|
||||
func TestFetch_共同依赖只装一次(t *testing.T) {
|
||||
r := newRegistry(t)
|
||||
r.add("a", "1.0.0", map[string]string{"shared": "^1.0.0"})
|
||||
r.add("b", "1.0.0", map[string]string{"shared": "^1.2.0"})
|
||||
r.add("shared", "1.5.0", nil)
|
||||
|
||||
got, err := client(r).Fetch(context.Background(), t.TempDir(), "a", "b")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := 0
|
||||
for _, p := range got {
|
||||
if p.Name == "shared" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("shared 装了 %d 次,该只装 1 次", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 主版本冲突要报错说清楚,而不是随便挑一个装上。
|
||||
func TestFetch_版本冲突要报错(t *testing.T) {
|
||||
r := newRegistry(t)
|
||||
r.add("a", "1.0.0", map[string]string{"dep": "^1.0.0"})
|
||||
r.add("b", "1.0.0", map[string]string{"dep": "^2.0.0"})
|
||||
r.add("dep", "1.0.0", nil)
|
||||
r.add("dep", "2.0.0", nil)
|
||||
|
||||
_, err := client(r).Fetch(context.Background(), t.TempDir(), "a", "b")
|
||||
if err == nil {
|
||||
t.Fatal("该报冲突")
|
||||
}
|
||||
for _, want := range []string{"dep", "冲突", "esm.Vendor"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("错误里该提到 %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 校验和对不上就得拒绝——中间的缓存代理、私有源镜像都可能改动内容。
|
||||
func TestFetch_校验和对不上就拒绝(t *testing.T) {
|
||||
r := newRegistry(t)
|
||||
r.add("bad", "1.0.0", nil)
|
||||
r.corrupt["bad"] = true
|
||||
|
||||
_, err := client(r).Fetch(context.Background(), t.TempDir(), "bad")
|
||||
if err == nil {
|
||||
t.Fatal("校验和不对时该报错")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "校验和") {
|
||||
t.Errorf("错误该说是校验和的问题: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 不写版本就取 latest。
|
||||
func TestFetch_不写版本取latest(t *testing.T) {
|
||||
r := newRegistry(t)
|
||||
r.add("p", "1.0.0", nil)
|
||||
r.add("p", "1.4.0", nil)
|
||||
|
||||
got, err := client(r).Fetch(context.Background(), t.TempDir(), "p")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got[0].Version != "1.4.0" {
|
||||
t.Errorf("装了 %s,该是 latest 1.4.0", got[0].Version)
|
||||
}
|
||||
}
|
||||
|
||||
// 指定范围要生效。
|
||||
func TestFetch_按范围挑版本(t *testing.T) {
|
||||
r := newRegistry(t)
|
||||
r.add("p", "1.0.0", nil)
|
||||
r.add("p", "1.4.0", nil)
|
||||
r.add("p", "2.0.0", nil)
|
||||
|
||||
got, err := client(r).Fetch(context.Background(), t.TempDir(), "p@^1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got[0].Version != "1.4.0" {
|
||||
t.Errorf("装了 %s,该是 1.4.0", got[0].Version)
|
||||
}
|
||||
}
|
||||
|
||||
// 包不存在要说清楚是哪个包。
|
||||
func TestFetch_包不存在(t *testing.T) {
|
||||
r := newRegistry(t)
|
||||
_, err := client(r).Fetch(context.Background(), t.TempDir(), "nope")
|
||||
if err == nil || !strings.Contains(err.Error(), "nope") {
|
||||
t.Errorf("该报错并提到包名: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// tarball 是外来数据,路径里带 ../ 的要挡住,不能写出目录之外。
|
||||
func TestFetch_挡住跳出目录的路径(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
zw := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(zw)
|
||||
body := "被写到外面去了"
|
||||
tw.WriteHeader(&tar.Header{
|
||||
Name: "package/../../../evil.js", Mode: 0o644,
|
||||
Size: int64(len(body)), Typeflag: tar.TypeReg,
|
||||
})
|
||||
tw.Write([]byte(body))
|
||||
tw.Close()
|
||||
zw.Close()
|
||||
evil := buf.Bytes()
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if strings.Contains(req.URL.Path, "/-/") {
|
||||
w.Write(evil)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"dist-tags": map[string]string{"latest": "1.0.0"},
|
||||
"versions": map[string]any{"1.0.0": map[string]any{
|
||||
"dist": map[string]string{
|
||||
"tarball": "http://" + req.Host + "/evil/-/evil-1.0.0.tgz",
|
||||
"integrity": integrityOf(evil),
|
||||
}}},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := &npm.Client{Registry: srv.URL, HTTP: srv.Client()}
|
||||
if _, err := c.Fetch(context.Background(), t.TempDir(), "evil"); err == nil {
|
||||
t.Fatal("带 ../ 的路径该被挡住")
|
||||
} else if !strings.Contains(err.Error(), "可疑路径") {
|
||||
t.Errorf("错误该说明是路径问题: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package npm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 这里只实现 npm semver 的一个**子集**。
|
||||
//
|
||||
// 抽了 8 个常见包的 44 个传递依赖统计,范围写法的分布是:
|
||||
//
|
||||
// ^ 95%
|
||||
// ~ 1 处
|
||||
// 精确 2 处
|
||||
//
|
||||
// 所以支持 ^ / ~ / 精确 / * / x / >= 就覆盖了实际会碰到的绝大多数。剩下的
|
||||
// (`||` 或者 `>=1 <2` 这种复合范围)**明确报错**,而不是猜一个版本——猜错了
|
||||
// 装出来的东西能跑但行为不对,比装不上难查得多。碰到了就用 npm 装,再走 Vendor。
|
||||
//
|
||||
// 另外一律**跳过预发布版本**(1.0.0-beta.1 这种),跟 npm 的默认行为一致。
|
||||
|
||||
type version struct {
|
||||
major, minor, patch int
|
||||
pre string // 预发布标识,非空表示这是个预发布版本
|
||||
}
|
||||
|
||||
func parseVersion(s string) (version, error) {
|
||||
var v version
|
||||
|
||||
// 先摘掉构建元数据(+sha),它不参与比较
|
||||
if i := strings.IndexByte(s, '+'); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
if i := strings.IndexByte(s, '-'); i >= 0 {
|
||||
v.pre, s = s[i+1:], s[:i]
|
||||
}
|
||||
|
||||
parts := strings.Split(s, ".")
|
||||
if len(parts) != 3 {
|
||||
return v, fmt.Errorf("版本号 %q 不是 x.y.z 的形式", s)
|
||||
}
|
||||
for i, p := range parts {
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("版本号 %q 里的 %q 不是数字", s, p)
|
||||
}
|
||||
switch i {
|
||||
case 0:
|
||||
v.major = n
|
||||
case 1:
|
||||
v.minor = n
|
||||
case 2:
|
||||
v.patch = n
|
||||
}
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (v version) String() string {
|
||||
s := fmt.Sprintf("%d.%d.%d", v.major, v.minor, v.patch)
|
||||
if v.pre != "" {
|
||||
s += "-" + v.pre
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// compare 只比主次修订三段。预发布版本我们根本不选,所以不用实现它的排序规则。
|
||||
func (v version) compare(o version) int {
|
||||
for _, p := range [][2]int{{v.major, o.major}, {v.minor, o.minor}, {v.patch, o.patch}} {
|
||||
if p[0] != p[1] {
|
||||
if p[0] < p[1] {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type rangeKind int
|
||||
|
||||
const (
|
||||
rangeAny rangeKind = iota // *、x、空
|
||||
rangeExact // 1.2.3
|
||||
rangeCaret // ^1.2.3
|
||||
rangeTilde // ~1.2.3
|
||||
rangeAtLeast // >=1.2.3
|
||||
)
|
||||
|
||||
// versionRange 是解析好的依赖范围。
|
||||
type versionRange struct {
|
||||
kind rangeKind
|
||||
base version
|
||||
// 写了几段。~1 和 ~1.2 的上界不一样,得知道原文写到哪一级
|
||||
parts int
|
||||
}
|
||||
|
||||
func parseRange(s string) (versionRange, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
|
||||
if s == "" || s == "*" || s == "x" || s == "latest" {
|
||||
return versionRange{kind: rangeAny}, nil
|
||||
}
|
||||
// 复合范围一律拒绝:与其猜,不如让人换条路
|
||||
if strings.ContainsAny(s, "|") || strings.Contains(s, " ") {
|
||||
return versionRange{}, fmt.Errorf("暂不支持复合范围 %q——"+
|
||||
"用 npm 装好之后走 esm.Vendor", s)
|
||||
}
|
||||
if strings.HasPrefix(s, "npm:") || strings.Contains(s, "/") || strings.Contains(s, ":") {
|
||||
return versionRange{}, fmt.Errorf("暂不支持别名或 git/文件依赖 %q", s)
|
||||
}
|
||||
|
||||
kind := rangeExact
|
||||
switch {
|
||||
case strings.HasPrefix(s, "^"):
|
||||
kind, s = rangeCaret, s[1:]
|
||||
case strings.HasPrefix(s, "~"):
|
||||
kind, s = rangeTilde, s[1:]
|
||||
case strings.HasPrefix(s, ">="):
|
||||
kind, s = rangeAtLeast, s[2:]
|
||||
case strings.HasPrefix(s, "="):
|
||||
s = s[1:]
|
||||
case strings.HasPrefix(s, "<"), strings.HasPrefix(s, ">"):
|
||||
return versionRange{}, fmt.Errorf("暂不支持范围 %q", s)
|
||||
}
|
||||
|
||||
// 补齐省略的段:1 → 1.0.0,1.2 → 1.2.0
|
||||
segs := strings.Split(s, ".")
|
||||
parts := len(segs)
|
||||
for i, seg := range segs {
|
||||
if seg == "x" || seg == "X" || seg == "*" {
|
||||
segs = segs[:i]
|
||||
parts = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if parts == 0 {
|
||||
return versionRange{kind: rangeAny}, nil
|
||||
}
|
||||
for len(segs) < 3 {
|
||||
segs = append(segs, "0")
|
||||
}
|
||||
|
||||
base, err := parseVersion(strings.Join(segs, "."))
|
||||
if err != nil {
|
||||
return versionRange{}, err
|
||||
}
|
||||
// 段数不全的写法(1、1.2、1.x、1.2.x)意思是"这几段固定,剩下的随便",
|
||||
// 跟 ~ 的语义一致:~1 锁主版本,~1.2 锁到次版本。
|
||||
//
|
||||
// 不能映射成 ^:^ 会对主版本为 0 的情况特殊处理(^0.2.3 锁次版本、
|
||||
// ^0.0.3 锁修订号),而 0.x 的本意是"任意 0.y.z",两者对不上。
|
||||
if parts < 3 && kind == rangeExact {
|
||||
kind = rangeTilde
|
||||
}
|
||||
return versionRange{kind: kind, base: base, parts: parts}, nil
|
||||
}
|
||||
|
||||
// allows 判断某个版本满不满足这个范围。预发布版本一律不选。
|
||||
func (r versionRange) allows(v version) bool {
|
||||
if v.pre != "" {
|
||||
return false
|
||||
}
|
||||
switch r.kind {
|
||||
case rangeAny:
|
||||
return true
|
||||
case rangeExact:
|
||||
return v.compare(r.base) == 0
|
||||
case rangeAtLeast:
|
||||
return v.compare(r.base) >= 0
|
||||
case rangeCaret:
|
||||
// ^ 锁住**最左边的非零段**:
|
||||
// ^1.2.3 → >=1.2.3 <2.0.0
|
||||
// ^0.2.3 → >=0.2.3 <0.3.0
|
||||
// ^0.0.3 → >=0.0.3 <0.0.4
|
||||
if v.compare(r.base) < 0 {
|
||||
return false
|
||||
}
|
||||
switch {
|
||||
case r.base.major > 0:
|
||||
return v.major == r.base.major
|
||||
case r.base.minor > 0:
|
||||
return v.major == 0 && v.minor == r.base.minor
|
||||
default:
|
||||
return v.major == 0 && v.minor == 0 && v.patch == r.base.patch
|
||||
}
|
||||
case rangeTilde:
|
||||
// ~1.2.3 / ~1.2 → 锁 1.2;~1 → 锁 1
|
||||
if v.compare(r.base) < 0 {
|
||||
return false
|
||||
}
|
||||
if r.parts == 1 {
|
||||
return v.major == r.base.major
|
||||
}
|
||||
return v.major == r.base.major && v.minor == r.base.minor
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pick 从一堆版本里挑满足范围的最高的那个。
|
||||
func pick(versions []string, r versionRange) (string, error) {
|
||||
var best version
|
||||
var bestRaw string
|
||||
|
||||
for _, raw := range versions {
|
||||
v, err := parseVersion(raw)
|
||||
if err != nil {
|
||||
continue // 注册表里偶尔有畸形版本号,跳过就是
|
||||
}
|
||||
if !r.allows(v) {
|
||||
continue
|
||||
}
|
||||
if bestRaw == "" || v.compare(best) > 0 {
|
||||
best, bestRaw = v, raw
|
||||
}
|
||||
}
|
||||
if bestRaw == "" {
|
||||
return "", fmt.Errorf("没有版本满足要求")
|
||||
}
|
||||
return bestRaw, nil
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package npm
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRange_挑版本(t *testing.T) {
|
||||
// 一个包发布过的所有版本,挑的时候要从里面选
|
||||
versions := []string{
|
||||
"0.0.1", "0.0.2", "0.1.0", "0.1.9", "0.2.0",
|
||||
"1.0.0", "1.2.3", "1.2.9", "1.9.0",
|
||||
"2.0.0", "2.1.0",
|
||||
"3.0.0-beta.1", // 预发布,任何情况下都不该被选中
|
||||
}
|
||||
|
||||
for _, c := range []struct {
|
||||
rng string
|
||||
want string
|
||||
why string
|
||||
}{
|
||||
{"^1.2.3", "1.9.0", "^ 锁主版本,取该主版本下最高的"},
|
||||
{"^1", "1.9.0", "只写主版本"},
|
||||
{"^2", "2.1.0", ""},
|
||||
{"^0.1.0", "0.1.9", "主版本是 0 时,^ 锁到次版本"},
|
||||
{"^0.0.1", "0.0.1", "主次都是 0 时,^ 锁死修订号"},
|
||||
{"~1.2.3", "1.2.9", "~ 锁到次版本"},
|
||||
{"~1.2", "1.2.9", ""},
|
||||
{"~1", "1.9.0", "只写主版本时 ~ 等于锁主版本"},
|
||||
{"1.2.3", "1.2.3", "精确"},
|
||||
{"=1.2.3", "1.2.3", ""},
|
||||
{"1.x", "1.9.0", "x 等价于锁住写出来的那几段"},
|
||||
{"0.x", "0.2.0", "主版本 0 时也是锁主版本,不能套 ^ 的特殊规则"},
|
||||
{"1.2.x", "1.2.9", ""},
|
||||
{">=2.0.0", "2.1.0", "只有下界"},
|
||||
{"*", "2.1.0", "任意版本取最高的——但不能是预发布"},
|
||||
{"", "2.1.0", "空范围同上"},
|
||||
} {
|
||||
r, err := parseRange(c.rng)
|
||||
if err != nil {
|
||||
t.Errorf("parseRange(%q) 报错: %v", c.rng, err)
|
||||
continue
|
||||
}
|
||||
got, err := pick(versions, r)
|
||||
if err != nil {
|
||||
t.Errorf("%q 挑不出版本: %v(%s)", c.rng, err, c.why)
|
||||
continue
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("%q → %s,该是 %s(%s)", c.rng, got, c.want, c.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 预发布版本一律不选,跟 npm 的默认行为一致。
|
||||
func TestRange_不选预发布(t *testing.T) {
|
||||
r, _ := parseRange("^3")
|
||||
if got, err := pick([]string{"3.0.0-beta.1", "3.0.0-rc.2"}, r); err == nil {
|
||||
t.Errorf("只有预发布版本时该挑不出来,却挑了 %s", got)
|
||||
}
|
||||
// 但正式版在的话要选得到
|
||||
r2, _ := parseRange("^3")
|
||||
if got, _ := pick([]string{"3.0.0-beta.1", "3.0.0"}, r2); got != "3.0.0" {
|
||||
t.Errorf("拿到 %s,该是 3.0.0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 支持不了的写法要**明确报错**,不能猜一个版本——猜错了装出来能跑但行为不对。
|
||||
func TestRange_不支持的写法要报错(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
"^1 || ^2", // 或
|
||||
">=1.0 <2.0", // 复合
|
||||
"<2.0.0", // 只有上界
|
||||
"npm:other@^1",
|
||||
"github:user/repo",
|
||||
"file:../local",
|
||||
} {
|
||||
if _, err := parseRange(s); err == nil {
|
||||
t.Errorf("%q 该报错,却解析成功了", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersion_解析(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
in string
|
||||
maj, min, pat int
|
||||
pre string
|
||||
}{
|
||||
{"1.2.3", 1, 2, 3, ""},
|
||||
{"0.0.1", 0, 0, 1, ""},
|
||||
{"10.20.30", 10, 20, 30, ""},
|
||||
{"1.2.3-beta.1", 1, 2, 3, "beta.1"},
|
||||
{"1.2.3+build.5", 1, 2, 3, ""}, // 构建元数据不参与比较,丢掉
|
||||
} {
|
||||
v, err := parseVersion(c.in)
|
||||
if err != nil {
|
||||
t.Errorf("parseVersion(%q): %v", c.in, err)
|
||||
continue
|
||||
}
|
||||
if v.major != c.maj || v.minor != c.min || v.patch != c.pat || v.pre != c.pre {
|
||||
t.Errorf("parseVersion(%q) = %d.%d.%d-%q", c.in, v.major, v.minor, v.patch, v.pre)
|
||||
}
|
||||
}
|
||||
|
||||
for _, bad := range []string{"1.2", "1", "a.b.c", "", "1.2.x"} {
|
||||
if _, err := parseVersion(bad); err == nil {
|
||||
t.Errorf("parseVersion(%q) 该报错", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSpec(t *testing.T) {
|
||||
for _, c := range []struct{ spec, name, rng string }{
|
||||
{"qs", "qs", ""},
|
||||
{"qs@6.16.0", "qs", "6.16.0"},
|
||||
{"qs@^6", "qs", "^6"},
|
||||
{"@scope/pkg", "@scope/pkg", ""},
|
||||
{"@scope/pkg@~1.2", "@scope/pkg", "~1.2"},
|
||||
} {
|
||||
name, rng := SplitSpec(c.spec)
|
||||
if name != c.name || rng != c.rng {
|
||||
t.Errorf("SplitSpec(%q) = %q, %q,该是 %q, %q", c.spec, name, rng, c.name, c.rng)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user