diff --git a/src/SDK/Language/CLI.php b/src/SDK/Language/CLI.php index b3b8281a84..88c835a47b 100644 --- a/src/SDK/Language/CLI.php +++ b/src/SDK/Language/CLI.php @@ -1221,6 +1221,16 @@ public function getFiles(): array 'destination' => 'internal/apns/expo/helper/package-lock.json', 'template' => 'cli/internal/apns/expo/helper/package-lock.json', ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/expo/runtime.go', + 'template' => 'cli/internal/apns/expo/runtime.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/expo/runtime_test.go', + 'template' => 'cli/internal/apns/expo/runtime_test.go', + ], [ 'scope' => 'default', 'destination' => 'internal/apns/fastlane/adapter.go', diff --git a/templates/cli/internal/apns/expo/adapter.go b/templates/cli/internal/apns/expo/adapter.go index 86eef419c7..5850e472da 100644 --- a/templates/cli/internal/apns/expo/adapter.go +++ b/templates/cli/internal/apns/expo/adapter.go @@ -4,7 +4,8 @@ // It runs a small Node script embedded in the binary, with its package.json // and a lockfile that pins @expo/apple-utils 2.2.1 (MIT) by hash. They are // installed into the session folder on first use with npm ci -// --ignore-scripts. It needs Node.js 18 or later. +// --ignore-scripts. It runs on Node.js 18 or later, and downloads the Node.js +// LTS into the session folder when the machine has none it can use. package expo import ( @@ -15,6 +16,7 @@ import ( "errors" "fmt" "io" + "net/http" "os" "os/exec" "path/filepath" @@ -31,9 +33,16 @@ const minimumNodeMajor = 18 // Adapter creates APNs keys with @expo/apple-utils. type Adapter struct { - // Node and Npm are the commands to run. Empty means "node" and "npm". + // Node and Npm are the commands to run. Empty means "node" and "npm" + // from PATH, or a downloaded Node.js when those are missing or too old. Node string Npm string + // For tests: where Node.js builds are downloaded from, the client, and + // the platform. Zero values mean nodejs.org and this machine. + NodeDist string + HTTP *http.Client + GOOS string + GOARCH string // apple-utils prompts for anything the request does not answer, and for // the two-factor code, on the terminal. Nil means the process's own. Stdin io.Reader @@ -68,8 +77,9 @@ type result struct { // creates keys for both APNs environments; for a single-environment request // that key works too. func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key, error) { - if err := a.checkNode(ctx); err != nil { - return apns.Key{}, err + log := request.Log + if log == nil { + log = func(string, ...any) {} } dir := request.SessionDir if dir == "" { @@ -80,11 +90,15 @@ func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key defer os.RemoveAll(scratch) dir = scratch } - if err := a.install(ctx, dir); err != nil { + tools, err := a.toolchain(ctx, dir, log) + if err != nil { + return apns.Key{}, err + } + if err := a.install(ctx, dir, tools); err != nil { return apns.Key{}, err } - if request.Environment != apns.EnvironmentAll && request.Log != nil { - request.Log("The expo setup creates keys for both APNs environments; the key also works for %s.", request.Environment) + if request.Environment != apns.EnvironmentAll { + log("The expo setup creates keys for both APNs environments; the key also works for %s.", request.Environment) } scratch, err := os.MkdirTemp("", "appwrite-apns-expo-result-") @@ -98,13 +112,13 @@ func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key if request.TeamID != "" { arguments = append(arguments, "--team-id", request.TeamID) } - command := exec.CommandContext(ctx, a.node(), arguments...) + command := exec.CommandContext(ctx, tools.node, arguments...) command.Dir = dir command.Stdin, command.Stdout, command.Stderr = a.terminal() // apple-utils reads the Apple ID and password from these instead of // asking, and stores the password in the macOS Keychain unless told not // to. The CLI never keeps the password. - command.Env = append(os.Environ(), "EXPO_NO_KEYCHAIN=1") + command.Env = append(tools.env(), "EXPO_NO_KEYCHAIN=1") if request.AppleID != "" { command.Env = append(command.Env, "EXPO_APPLE_ID="+request.AppleID) } @@ -170,14 +184,6 @@ func (a *Adapter) terminal() (io.Reader, io.Writer, io.Writer) { return stdin, stdout, stderr } -func (a *Adapter) node() string { - if a.Node != "" { - return a.Node - } - - return "node" -} - func (a *Adapter) npm() string { if a.Npm != "" { return a.Npm @@ -188,8 +194,8 @@ func (a *Adapter) npm() string { var nodeVersion = regexp.MustCompile(`^v?(\d+)\.`) -func (a *Adapter) checkNode(ctx context.Context) error { - output, err := exec.CommandContext(ctx, a.node(), "--version").Output() +func checkNode(ctx context.Context, node string) error { + output, err := exec.CommandContext(ctx, node, "--version").Output() if err != nil { return fmt.Errorf("%w: the expo setup needs Node.js %d or later", apns.ErrUnavailable, minimumNodeMajor) } @@ -197,6 +203,50 @@ func (a *Adapter) checkNode(ctx context.Context) error { return supportedNode(string(bytes.TrimSpace(output))) } +// toolchain is how node and npm are run. +type toolchain struct { + node string + npm []string + // bin is put first on PATH when node is a downloaded one. + bin string +} + +func (t toolchain) env() []string { + env := os.Environ() + if t.bin != "" { + env = append(env, "PATH="+t.bin+string(os.PathListSeparator)+os.Getenv("PATH")) + } + + return env +} + +// toolchain uses the configured node, then node and npm from PATH, then the +// Node.js downloaded into dir. A configured node is never replaced. +func (a *Adapter) toolchain(ctx context.Context, dir string, log func(string, ...any)) (toolchain, error) { + if a.Node != "" { + if err := checkNode(ctx, a.Node); err != nil { + return toolchain{}, err + } + + return toolchain{node: a.Node, npm: []string{a.npm()}}, nil + } + if checkNode(ctx, "node") == nil { + if _, err := exec.LookPath(a.npm()); err == nil { + return toolchain{node: "node", npm: []string{a.npm()}}, nil + } + } + + node, npm, err := a.downloadNode(ctx, filepath.Join(dir, "runtime"), log) + if err != nil { + return toolchain{}, fmt.Errorf("%w: the expo setup needs Node.js %d or later, and downloading it failed: %v", apns.ErrUnavailable, minimumNodeMajor, err) + } + if err := checkNode(ctx, node); err != nil { + return toolchain{}, err + } + + return toolchain{node: node, npm: []string{node, npm}, bin: filepath.Dir(node)}, nil +} + func supportedNode(version string) error { match := nodeVersion.FindStringSubmatch(version) if match == nil { @@ -213,7 +263,7 @@ func supportedNode(version string) error { // lockfile changed or the dependencies are missing. The lockfile pins the // exact package hashes, and --ignore-scripts keeps install scripts from // running. -func (a *Adapter) install(ctx context.Context, dir string) error { +func (a *Adapter) install(ctx context.Context, dir string, tools toolchain) error { if err := os.MkdirAll(dir, 0o700); err != nil { return err } @@ -237,8 +287,10 @@ func (a *Adapter) install(ctx context.Context, dir string) error { return nil } _, _, stderr := a.terminal() - command := exec.CommandContext(ctx, a.npm(), "ci", "--ignore-scripts", "--no-audit", "--no-fund", "--loglevel=error") + arguments := append(append([]string{}, tools.npm[1:]...), "ci", "--ignore-scripts", "--no-audit", "--no-fund", "--loglevel=error") + command := exec.CommandContext(ctx, tools.npm[0], arguments...) command.Dir = dir + command.Env = tools.env() command.Stdout, command.Stderr = stderr, stderr if err := command.Run(); err != nil { // A failed install must not look complete on the next run. diff --git a/templates/cli/internal/apns/expo/runtime.go b/templates/cli/internal/apns/expo/runtime.go new file mode 100644 index 0000000000..fb7e357d4c --- /dev/null +++ b/templates/cli/internal/apns/expo/runtime.go @@ -0,0 +1,278 @@ +package expo + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" +) + +// When the machine has no Node.js the expo setup can use, it downloads this +// release, the Active LTS, into the session folder. Nothing is installed +// system-wide. Each build is checked against its SHA-256 from +// https://nodejs.org/dist/v24.21.0/SHASUMS256.txt before it is unpacked. +const nodeRelease = "v24.21.0" + +var nodeChecksums = map[string]string{ + "node-v24.21.0-darwin-arm64.tar.gz": "bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057", + "node-v24.21.0-darwin-x64.tar.gz": "1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097", + "node-v24.21.0-linux-arm64.tar.gz": "724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5", + "node-v24.21.0-linux-x64.tar.gz": "6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff", + "node-v24.21.0-win-arm64.zip": "8779b1bde1d39f8d420e3b57aa657b39891af434d3de44a919044cec06785921", + "node-v24.21.0-win-x64.zip": "158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541", +} + +const defaultNodeDist = "https://nodejs.org/dist" + +// nodeBuild names the official build for goos/goarch, without its extension, +// and the archive format it comes in. +func nodeBuild(goos, goarch string) (string, string, error) { + platform := map[string]string{"darwin": "darwin", "linux": "linux", "windows": "win"}[goos] + arch := map[string]string{"amd64": "x64", "arm64": "arm64"}[goarch] + if platform == "" || arch == "" { + return "", "", fmt.Errorf("no Node.js build to download for %s/%s", goos, goarch) + } + extension := ".tar.gz" + if goos == "windows" { + extension = ".zip" + } + + return "node-" + nodeRelease + "-" + platform + "-" + arch, extension, nil +} + +// nodePaths are the node binary and npm's entry script inside an unpacked +// build. npm is run through node, so its shell wrappers are not needed. +func nodePaths(root, goos string) (string, string) { + if goos == "windows" { + return filepath.Join(root, "node.exe"), filepath.Join(root, "node_modules", "npm", "bin", "npm-cli.js") + } + + return filepath.Join(root, "bin", "node"), filepath.Join(root, "lib", "node_modules", "npm", "bin", "npm-cli.js") +} + +// downloadNode makes sure the pinned Node.js is unpacked under dir and returns +// the node binary and npm's entry script. +func (a *Adapter) downloadNode(ctx context.Context, dir string, log func(string, ...any)) (string, string, error) { + goos, goarch := a.platform() + name, extension, err := nodeBuild(goos, goarch) + if err != nil { + return "", "", err + } + root := filepath.Join(dir, name) + node, npm := nodePaths(root, goos) + if _, err := os.Stat(npm); err == nil { + return node, npm, nil + } + archive := name + extension + checksum, ok := nodeChecksums[archive] + if !ok { + return "", "", fmt.Errorf("no checksum for %s", archive) + } + + dist := a.NodeDist + if dist == "" { + dist = defaultNodeDist + } + log("Node.js %d or later was not found. Downloading Node.js %s (about 50 MB, 200 MB unpacked) into %s ...", minimumNodeMajor, nodeRelease, dir) + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", "", err + } + downloaded, err := a.fetch(ctx, dist+"/"+nodeRelease+"/"+archive, dir, checksum) + if err != nil { + return "", "", err + } + defer os.Remove(downloaded) + + // Unpack next to the final folder and move it into place, so a failed or + // interrupted unpack never looks like a usable runtime. + staging, err := os.MkdirTemp(dir, ".node-") + if err != nil { + return "", "", err + } + defer os.RemoveAll(staging) + if extension == ".zip" { + err = unzip(downloaded, staging) + } else { + err = untar(downloaded, staging) + } + if err != nil { + return "", "", fmt.Errorf("could not unpack %s: %w", archive, err) + } + // Publishing is a rename, which fails when another run published first; + // that runtime is complete, so it is used rather than replaced. An + // existing runtime is never removed, as another run may be using it. + if err := os.Rename(filepath.Join(staging, name), root); err != nil { + if _, statErr := os.Stat(npm); statErr == nil { + return node, npm, nil + } + + return "", "", fmt.Errorf("could not unpack %s: %w", archive, err) + } + if _, err := os.Stat(npm); err != nil { + return "", "", fmt.Errorf("%s has no npm", archive) + } + + return node, npm, nil +} + +func (a *Adapter) platform() (string, string) { + if a.GOOS != "" { + return a.GOOS, a.GOARCH + } + + return runtime.GOOS, runtime.GOARCH +} + +// fetch downloads url into a file in dir and checks its SHA-256. +func (a *Adapter) fetch(ctx context.Context, url, dir, checksum string) (string, error) { + client := a.HTTP + if client == nil { + client = &http.Client{Timeout: 10 * time.Minute} + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", err + } + response, err := client.Do(request) + if err != nil { + return "", fmt.Errorf("could not download Node.js: %w", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return "", fmt.Errorf("could not download Node.js from %s (HTTP %d)", url, response.StatusCode) + } + + file, err := os.CreateTemp(dir, ".node-download-") + if err != nil { + return "", err + } + hash := sha256.New() + _, err = io.Copy(io.MultiWriter(file, hash), response.Body) + if closeErr := file.Close(); err == nil { + err = closeErr + } + if err != nil { + os.Remove(file.Name()) + + return "", fmt.Errorf("could not download Node.js: %w", err) + } + if got := hex.EncodeToString(hash.Sum(nil)); got != checksum { + os.Remove(file.Name()) + + return "", fmt.Errorf("the Node.js download from %s does not match its checksum (got %s)", url, got) + } + + return file.Name(), nil +} + +// inside returns where name unpacks under root, refusing anything that would +// land outside it. +func inside(root, name string) (string, error) { + clean := filepath.Clean(filepath.FromSlash(name)) + if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("unsafe path %q in the archive", name) + } + + return filepath.Join(root, clean), nil +} + +func writeFile(path string, mode os.FileMode, contents io.Reader) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode&0o755|0o600) + if err != nil { + return err + } + _, err = io.Copy(file, contents) + if closeErr := file.Close(); err == nil { + err = closeErr + } + + return err +} + +// untar unpacks a .tar.gz into root. Symbolic links are skipped: they are +// only npm's and npx's shell entry points, and npm is run through node. +func untar(archive, root string) error { + file, err := os.Open(archive) + if err != nil { + return err + } + defer file.Close() + compressed, err := gzip.NewReader(file) + if err != nil { + return err + } + reader := tar.NewReader(compressed) + for { + header, err := reader.Next() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + path, err := inside(root, header.Name) + if err != nil { + return err + } + switch header.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(path, 0o755); err != nil { + return err + } + case tar.TypeReg: + if err := writeFile(path, header.FileInfo().Mode(), reader); err != nil { + return err + } + } + } +} + +// unzip unpacks a .zip into root, with the same rules as untar. +func unzip(archive, root string) error { + reader, err := zip.OpenReader(archive) + if err != nil { + return err + } + defer reader.Close() + for _, entry := range reader.File { + path, err := inside(root, entry.Name) + if err != nil { + return err + } + if entry.FileInfo().IsDir() { + if err := os.MkdirAll(path, 0o755); err != nil { + return err + } + + continue + } + if !entry.FileInfo().Mode().IsRegular() { + continue + } + contents, err := entry.Open() + if err != nil { + return err + } + err = writeFile(path, entry.FileInfo().Mode(), contents) + contents.Close() + if err != nil { + return err + } + } + + return nil +} diff --git a/templates/cli/internal/apns/expo/runtime_test.go b/templates/cli/internal/apns/expo/runtime_test.go new file mode 100644 index 0000000000..5d7c2319e2 --- /dev/null +++ b/templates/cli/internal/apns/expo/runtime_test.go @@ -0,0 +1,237 @@ +package expo + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + "testing" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +func TestNodeBuild(t *testing.T) { + for _, test := range []struct{ goos, goarch, name, extension string }{ + {"darwin", "arm64", "node-v24.21.0-darwin-arm64", ".tar.gz"}, + {"darwin", "amd64", "node-v24.21.0-darwin-x64", ".tar.gz"}, + {"linux", "amd64", "node-v24.21.0-linux-x64", ".tar.gz"}, + {"windows", "arm64", "node-v24.21.0-win-arm64", ".zip"}, + } { + name, extension, err := nodeBuild(test.goos, test.goarch) + if err != nil || name != test.name || extension != test.extension { + t.Errorf("nodeBuild(%s, %s) = %s, %s, %v", test.goos, test.goarch, name, extension, err) + } + if _, ok := nodeChecksums[name+extension]; !ok { + t.Errorf("no checksum for %s%s", name, extension) + } + } + if _, _, err := nodeBuild("plan9", "386"); err == nil { + t.Error("found a build for plan9/386") + } +} + +type entry struct { + name string + mode int64 + contents string + link string +} + +func tarball(t *testing.T, entries []entry) []byte { + t.Helper() + var buffer bytes.Buffer + compressed := gzip.NewWriter(&buffer) + writer := tar.NewWriter(compressed) + for _, item := range entries { + header := &tar.Header{Name: item.name, Mode: item.mode, Size: int64(len(item.contents)), Typeflag: tar.TypeReg} + if item.link != "" { + header = &tar.Header{Name: item.name, Mode: 0o777, Linkname: item.link, Typeflag: tar.TypeSymlink} + } + if err := writer.WriteHeader(header); err != nil { + t.Fatal(err) + } + if _, err := writer.Write([]byte(item.contents)); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if err := compressed.Close(); err != nil { + t.Fatal(err) + } + + return buffer.Bytes() +} + +// fakeNodeBuild is a Node.js build whose node is a shell script: it answers +// --version, acts as npm when given npm-cli.js, and otherwise runs as the +// helper, writing a key to the --out file. +func fakeNodeBuild(t *testing.T) (string, []byte) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("shell stand-ins") + } + name, _, err := nodeBuild(runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skip(err) + } + node := `#!/bin/sh +if [ "$1" = "--version" ]; then echo "v24.21.0"; exit 0; fi +case "$1" in + *npm-cli.js) mkdir -p node_modules/@expo/apple-utils && echo '{}' > node_modules/@expo/apple-utils/package.json; exit 0 ;; +esac +while [ $# -gt 0 ]; do + if [ "$1" = "--out" ]; then printf '%s' '{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}' > "$2"; fi + shift +done +` + + return name, tarball(t, []entry{ + {name: name + "/bin/node", mode: 0o755, contents: node}, + {name: name + "/lib/node_modules/npm/bin/npm-cli.js", mode: 0o644, contents: "// npm"}, + {name: name + "/bin/npm", link: "../lib/node_modules/npm/bin/npm-cli.js"}, + }) +} + +// serve answers the Node.js download, counting requests, and makes archive +// the expected build for this machine. +func serve(t *testing.T, name string, archive []byte) (*httptest.Server, *atomic.Int32) { + t.Helper() + sum := sha256.Sum256(archive) + previous := nodeChecksums[name+".tar.gz"] + nodeChecksums[name+".tar.gz"] = hex.EncodeToString(sum[:]) + t.Cleanup(func() { nodeChecksums[name+".tar.gz"] = previous }) + + requests := &atomic.Int32{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.URL.Path != "/"+nodeRelease+"/"+name+".tar.gz" { + w.WriteHeader(http.StatusNotFound) + + return + } + _, _ = w.Write(archive) + })) + t.Cleanup(server.Close) + + return server, requests +} + +// withoutNode leaves only mkdir on PATH, so neither node nor npm is found. +func withoutNode(t *testing.T) { + t.Helper() + bin := t.TempDir() + if err := os.Symlink("/bin/mkdir", filepath.Join(bin, "mkdir")); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin) +} + +func TestCreateKeyDownloadsNodeWhenMissing(t *testing.T) { + name, archive := fakeNodeBuild(t) + server, requests := serve(t, name, archive) + withoutNode(t) + + adapter := &Adapter{NodeDist: server.URL, HTTP: server.Client(), Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + var logged []string + request := apns.Request{ + TeamID: "ABCDE12345", Name: "Appwrite Push", Environment: apns.EnvironmentAll, SessionDir: t.TempDir(), + Log: func(format string, args ...any) { logged = append(logged, format) }, + } + + for range 2 { + key, err := adapter.CreateKey(context.Background(), request) + if err != nil || key.KeyID != "KEY1234567" { + t.Fatalf("key = %+v, %v", key, err) + } + } + if requests.Load() != 1 { + t.Errorf("downloaded Node.js %d times", requests.Load()) + } + if len(logged) != 1 || !strings.Contains(logged[0], "Downloading Node.js %s") { + t.Errorf("logged %v", logged) + } + root := filepath.Join(request.SessionDir, "runtime", name) + if info, err := os.Stat(filepath.Join(root, "bin", "node")); err != nil || info.Mode().Perm()&0o100 == 0 { + t.Errorf("node = %v, %v", info, err) + } + if _, err := os.Lstat(filepath.Join(root, "bin", "npm")); err == nil { + t.Error("unpacked a symbolic link") + } +} + +func TestDownloadNodeRejectsABadChecksum(t *testing.T) { + name, archive := fakeNodeBuild(t) + server, _ := serve(t, name, archive) + nodeChecksums[name+".tar.gz"] = strings.Repeat("0", 64) + withoutNode(t) + + adapter := &Adapter{NodeDist: server.URL, HTTP: server.Client()} + dir := t.TempDir() + _, err := adapter.CreateKey(context.Background(), apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, SessionDir: dir}) + if err == nil || !strings.Contains(err.Error(), "does not match its checksum") { + t.Fatalf("err = %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "runtime", name)); err == nil { + t.Error("unpacked a download that failed its checksum") + } +} + +func TestUntarRefusesPathsOutsideTheFolder(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "root") + archive := filepath.Join(parent, "evil.tar.gz") + if err := os.WriteFile(archive, tarball(t, []entry{ + {name: "../evil", mode: 0o644, contents: "x"}, + }), 0o600); err != nil { + t.Fatal(err) + } + + if err := untar(archive, root); err == nil || !strings.Contains(err.Error(), "unsafe path") { + t.Errorf("err = %v", err) + } + if _, err := os.Stat(filepath.Join(parent, "evil")); err == nil { + t.Error("wrote outside the folder") + } +} + +func TestConcurrentFirstRunsShareOneRuntime(t *testing.T) { + name, archive := fakeNodeBuild(t) + server, _ := serve(t, name, archive) + dir := t.TempDir() + + const runs = 4 + errs := make(chan error, runs) + for range runs { + go func() { + adapter := &Adapter{NodeDist: server.URL, HTTP: server.Client()} + node, _, err := adapter.downloadNode(context.Background(), dir, func(string, ...any) {}) + if err == nil { + _, err = os.Stat(node) + } + errs <- err + }() + } + for range runs { + if err := <-errs; err != nil { + t.Error(err) + } + } + if _, err := os.Stat(filepath.Join(dir, name, "lib", "node_modules", "npm", "bin", "npm-cli.js")); err != nil { + t.Errorf("runtime is incomplete after concurrent runs: %v", err) + } + leftovers, _ := filepath.Glob(filepath.Join(dir, ".node-*")) + if len(leftovers) != 0 { + t.Errorf("left staging files behind: %v", leftovers) + } +}