From fa93a7b6629bc6d8fd8095aba5496c25ebfe79cd Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 15:41:57 +0530 Subject: [PATCH 1/2] feat(cli): download Node.js for the expo APNs key setup when missing The expo setup stopped when node or npm was missing, or node was older than 18. It now downloads the Node.js LTS (v24.21.0) for this OS and CPU from nodejs.org into the setup's session folder, checks the archive against the SHA-256 pinned for each official build, unpacks it beside its final folder and moves it into place, and runs npm through node. Nothing is installed system-wide, and later runs reuse it. Entries that would unpack outside the folder are refused and symbolic links (npm's shell wrappers) are skipped. A node set on the adapter is checked but never replaced. --- src/SDK/Language/CLI.php | 10 + templates/cli/internal/apns/expo/adapter.go | 94 ++++-- templates/cli/internal/apns/expo/runtime.go | 272 ++++++++++++++++++ .../cli/internal/apns/expo/runtime_test.go | 206 +++++++++++++ 4 files changed, 561 insertions(+), 21 deletions(-) create mode 100644 templates/cli/internal/apns/expo/runtime.go create mode 100644 templates/cli/internal/apns/expo/runtime_test.go 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..988a421871 --- /dev/null +++ b/templates/cli/internal/apns/expo/runtime.go @@ -0,0 +1,272 @@ +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) + } + _ = os.RemoveAll(root) + if err := os.Rename(filepath.Join(staging, name), root); err != 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..e7f9a01d69 --- /dev/null +++ b/templates/cli/internal/apns/expo/runtime_test.go @@ -0,0 +1,206 @@ +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") + } +} From f46478f64db4a14f926983d324d51e4206b63353 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 15:58:54 +0530 Subject: [PATCH 2/2] fix(cli): never remove a published Node.js runtime Two expo setups starting together in the same session folder could both download Node.js, and the later one removed the runtime the earlier one had just published, maybe while it was running npm or the helper. Publishing is now only a rename: an existing runtime is never removed, and when another run published first, its complete runtime is used. --- templates/cli/internal/apns/expo/runtime.go | 8 ++++- .../cli/internal/apns/expo/runtime_test.go | 31 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/templates/cli/internal/apns/expo/runtime.go b/templates/cli/internal/apns/expo/runtime.go index 988a421871..fb7e357d4c 100644 --- a/templates/cli/internal/apns/expo/runtime.go +++ b/templates/cli/internal/apns/expo/runtime.go @@ -109,8 +109,14 @@ func (a *Adapter) downloadNode(ctx context.Context, dir string, log func(string, if err != nil { return "", "", fmt.Errorf("could not unpack %s: %w", archive, err) } - _ = os.RemoveAll(root) + // 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 { diff --git a/templates/cli/internal/apns/expo/runtime_test.go b/templates/cli/internal/apns/expo/runtime_test.go index e7f9a01d69..5d7c2319e2 100644 --- a/templates/cli/internal/apns/expo/runtime_test.go +++ b/templates/cli/internal/apns/expo/runtime_test.go @@ -204,3 +204,34 @@ func TestUntarRefusesPathsOutsideTheFolder(t *testing.T) { 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) + } +}