diff --git a/CHANGELOG.md b/CHANGELOG.md index d2d86e4..cf9f871 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Dependencies: update Go to 1.26, npm to 12.0.1, Playwright to 1.61.1, current Go terminal/system libraries, and cached Chrome cookie support to 3.0.2 with audited transitive overrides. - Docker: add a local image with Node, Playwright Chromium, `/data` persistence, and CI smoke coverage. - Add Sweden (`SE`) Foodora preset using `OP_SE`. (`#4`, thanks `@grenish`) - Add Czech Republic (`CZ`) Foodora preset using `DJ_CZ`. (`#6`, thanks `@usimic`) diff --git a/Dockerfile b/Dockerfile index cace1e1..1c5d99a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,8 @@ # syntax=docker/dockerfile:1.7 -ARG GO_VERSION=1.25 -ARG PLAYWRIGHT_VERSION=1.58.2 +ARG GO_VERSION=1.26 +ARG NPM_VERSION=12.0.1 +ARG PLAYWRIGHT_VERSION=1.61.1 FROM golang:${GO_VERSION}-bookworm AS build WORKDIR /src @@ -10,10 +11,12 @@ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/ordercli ./cmd/ordercli -FROM node:24-bookworm-slim +FROM node:24.18.0-bookworm-slim +ARG NPM_VERSION ARG PLAYWRIGHT_VERSION ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright -RUN apt-get update \ +RUN npm install --global npm@${NPM_VERSION} \ + && apt-get update \ && apt-get install -y --no-install-recommends ca-certificates \ && npx --yes playwright@${PLAYWRIGHT_VERSION} install --with-deps chromium \ && rm -rf /var/lib/apt/lists/* /tmp/* \ diff --git a/go.mod b/go.mod index 111c974..b850ca5 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,14 @@ module github.com/steipete/ordercli -go 1.25.0 +go 1.26.0 require ( github.com/spf13/cobra v1.10.2 - golang.org/x/term v0.44.0 + golang.org/x/term v0.45.0 ) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.10 // indirect - golang.org/x/sys v0.46.0 // indirect + golang.org/x/sys v0.47.0 // indirect ) diff --git a/go.sum b/go.sum index e347ab0..6eda787 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,8 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= -golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/browserauth/browserauth.go b/internal/browserauth/browserauth.go index 58dfb29..5a97612 100644 --- a/internal/browserauth/browserauth.go +++ b/internal/browserauth/browserauth.go @@ -62,7 +62,7 @@ func OAuthTokenPassword(ctx context.Context, req foodora.OAuthPasswordRequest, o } pw := opts.Playwright if pw == "" { - pw = "playwright@1.58.2" + pw = "playwright@1.61.1" } td, err := os.MkdirTemp("", "ordercli-browserauth-*") @@ -222,7 +222,8 @@ func runAuthScriptReal(ctx context.Context, td, scriptPath, outPath string, inpu } else { install.Stderr = io.Discard } - install.Env = append(os.Environ(), + install.Env = append( + os.Environ(), "npm_config_loglevel=error", ) if err := install.Run(); err != nil { @@ -241,7 +242,8 @@ func runAuthScriptReal(ctx context.Context, td, scriptPath, outPath string, inpu } else { installBrowsers.Stderr = io.Discard } - installBrowsers.Env = append(os.Environ(), + installBrowsers.Env = append( + os.Environ(), "npm_config_loglevel=error", ) if err := installBrowsers.Run(); err != nil { @@ -250,7 +252,8 @@ func runAuthScriptReal(ctx context.Context, td, scriptPath, outPath string, inpu cmd := exec.CommandContext(cmdCtx, "node", scriptPath) //nolint:gosec cmd.Dir = td - cmd.Env = append(os.Environ(), + cmd.Env = append( + os.Environ(), "ORDERCLI_OUTPUT_PATH="+outPath, "FOODCLI_OUTPUT_PATH="+outPath, "FOODORACLI_OUTPUT_PATH="+outPath, // legacy diff --git a/internal/browserpage/fetch.go b/internal/browserpage/fetch.go index 09290a0..683ba46 100644 --- a/internal/browserpage/fetch.go +++ b/internal/browserpage/fetch.go @@ -50,7 +50,7 @@ func ReadText(ctx context.Context, targetURL string, opts Options) (Result, erro } pw := strings.TrimSpace(opts.Playwright) if pw == "" { - pw = "playwright@1.58.2" + pw = "playwright@1.61.1" } td, err := os.MkdirTemp("", "ordercli-browserpage-*") @@ -127,7 +127,8 @@ func runFetchScript(ctx context.Context, td, scriptPath, outPath string, input [ cmd := exec.CommandContext(cmdCtx, "node", scriptPath) //nolint:gosec cmd.Dir = td - cmd.Env = append(os.Environ(), + cmd.Env = append( + os.Environ(), "ORDERCLI_OUTPUT_PATH="+outPath, "npm_config_loglevel=error", ) diff --git a/internal/browserpage/fetch_test.go b/internal/browserpage/fetch_test.go index 227768c..4193373 100644 --- a/internal/browserpage/fetch_test.go +++ b/internal/browserpage/fetch_test.go @@ -38,7 +38,7 @@ func TestReadText_UsesDefaultsAndDecodesResult(t *testing.T) { if opts.Timeout != 2*time.Minute { t.Fatalf("opts timeout=%s", opts.Timeout) } - if playwright != "playwright@1.58.2" { + if playwright != "playwright@1.61.1" { t.Fatalf("playwright=%q", playwright) } return []byte(`{"final_url":"https://example.com/final","title":"T","text":"Body"}`), nil @@ -70,7 +70,7 @@ func TestReadText_InvalidJSON(t *testing.T) { func TestRunFetchScript_NodeMissing(t *testing.T) { t.Setenv("PATH", "") - _, err := runFetchScript(context.Background(), t.TempDir(), "script.mjs", "out.json", nil, Options{Timeout: time.Second}, "playwright@1.58.2") + _, err := runFetchScript(context.Background(), t.TempDir(), "script.mjs", "out.json", nil, Options{Timeout: time.Second}, "playwright@1.61.1") if err == nil || !strings.Contains(err.Error(), "node not found") { t.Fatalf("err=%v", err) } @@ -84,7 +84,7 @@ func TestRunFetchScript_NpmMissing(t *testing.T) { } t.Setenv("PATH", binDir) - _, err := runFetchScript(context.Background(), t.TempDir(), "script.mjs", "out.json", nil, Options{Timeout: time.Second}, "playwright@1.58.2") + _, err := runFetchScript(context.Background(), t.TempDir(), "script.mjs", "out.json", nil, Options{Timeout: time.Second}, "playwright@1.61.1") if err == nil || !strings.Contains(err.Error(), "npm not found") { t.Fatalf("err=%v", err) } diff --git a/internal/chromecookies/chromecookies.go b/internal/chromecookies/chromecookies.go index 4bce609..9262a6f 100644 --- a/internal/chromecookies/chromecookies.go +++ b/internal/chromecookies/chromecookies.go @@ -11,12 +11,19 @@ import ( "os" "os/exec" "path/filepath" + "strings" "time" ) //go:embed load.mjs var loadScript []byte +// Keep the install-time overrides until chrome-cookies-secure drops its vulnerable build chain. +const ( + npmPackageJSON = `{"private":true,"type":"module","dependencies":{"chrome-cookies-secure":"3.0.2"},"overrides":{"tar":"7.5.20","@tootallnate/once":"2.0.1"}}` + npmStateFile = ".ordercli-dependencies" +) + type Options struct { TargetURL string ChromeProfile string @@ -99,12 +106,11 @@ type scriptOutput struct { var runScript = runScriptReal func ensureNpmProject(ctx context.Context, dir string, logWriter io.Writer) error { - nodeModules := filepath.Join(dir, "node_modules", "chrome-cookies-secure", "package.json") - if _, err := os.Stat(nodeModules); err == nil { + if npmProjectCurrent(dir) { return nil } - pkg := []byte("{\"private\":true,\"type\":\"module\",\"dependencies\":{\"chrome-cookies-secure\":\"3.0.0\"}}\n") + pkg := []byte(npmPackageJSON + "\n") if err := os.WriteFile(filepath.Join(dir, "package.json"), pkg, 0o600); err != nil { return err } @@ -120,12 +126,78 @@ func ensureNpmProject(ctx context.Context, dir string, logWriter io.Writer) erro } else { install.Stderr = io.Discard } - install.Env = append(os.Environ(), + install.Env = append( + os.Environ(), "npm_config_loglevel=error", ) if err := install.Run(); err != nil { return fmt.Errorf("chromecookies: npm install chrome-cookies-secure: %w", err) } + if err := verifyInstalledNpmDependencies(dir); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(dir, npmStateFile), []byte(npmPackageJSON+"\n"), 0o600); err != nil { + return fmt.Errorf("chromecookies: write dependency state: %w", err) + } + return nil +} + +func npmProjectCurrent(dir string) bool { + pkg, err := os.ReadFile(filepath.Join(dir, "package.json")) + if err != nil || strings.TrimSpace(string(pkg)) != npmPackageJSON { + return false + } + state, err := os.ReadFile(filepath.Join(dir, npmStateFile)) + if err != nil || strings.TrimSpace(string(state)) != npmPackageJSON { + return false + } + _, err = os.Stat(filepath.Join(dir, "node_modules", "chrome-cookies-secure", "package.json")) + return err == nil +} + +func verifyInstalledNpmDependencies(dir string) error { + required := map[string]string{ + "chrome-cookies-secure": "3.0.2", + "tar": "7.5.20", + "@tootallnate/once": "2.0.1", + } + seen := make(map[string]bool, len(required)) + err := filepath.WalkDir(filepath.Join(dir, "node_modules"), func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() || entry.Name() != "package.json" { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + var pkg struct { + Name string `json:"name"` + Version string `json:"version"` + } + if err := json.Unmarshal(data, &pkg); err != nil { + return nil + } + want, ok := required[pkg.Name] + if !ok { + return nil + } + if pkg.Version != want { + return fmt.Errorf("chromecookies: installed %s version %s, want %s", pkg.Name, pkg.Version, want) + } + seen[pkg.Name] = true + return nil + }) + if err != nil { + return fmt.Errorf("chromecookies: verify npm dependencies: %w", err) + } + for name := range required { + if !seen[name] { + return fmt.Errorf("chromecookies: installed dependency %s missing", name) + } + } return nil } @@ -142,7 +214,8 @@ func runScriptReal(ctx context.Context, cacheDir, scriptPath, outPath string, in cmd := exec.CommandContext(cmdCtx, "node", scriptPath) //nolint:gosec cmd.Dir = cacheDir - cmd.Env = append(os.Environ(), + cmd.Env = append( + os.Environ(), "ORDERCLI_OUTPUT_PATH="+outPath, "FOODCLI_OUTPUT_PATH="+outPath, "FOODORACLI_OUTPUT_PATH="+outPath, // legacy diff --git a/internal/chromecookies/chromecookies_test.go b/internal/chromecookies/chromecookies_test.go index e4a90d4..9fe1c0d 100644 --- a/internal/chromecookies/chromecookies_test.go +++ b/internal/chromecookies/chromecookies_test.go @@ -24,13 +24,7 @@ func TestLoadCookieHeader_Validation(t *testing.T) { func TestEnsureNpmProject_ShortCircuit(t *testing.T) { dir := t.TempDir() - p := filepath.Join(dir, "node_modules", "chrome-cookies-secure", "package.json") - if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { - t.Fatalf("mkdir: %v", err) - } - if err := os.WriteFile(p, []byte("{}"), 0o600); err != nil { - t.Fatalf("write: %v", err) - } + seedNpmProject(t, dir) if err := ensureNpmProject(context.Background(), dir, nil); err != nil { t.Fatalf("expected nil, got %v", err) @@ -46,13 +40,7 @@ func TestLoadCookieHeader_Success_WithStubRunner(t *testing.T) { } cacheDir := t.TempDir() - p := filepath.Join(cacheDir, "node_modules", "chrome-cookies-secure", "package.json") - if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { - t.Fatalf("mkdir: %v", err) - } - if err := os.WriteFile(p, []byte("{}"), 0o600); err != nil { - t.Fatalf("write: %v", err) - } + seedNpmProject(t, cacheDir) res, err := LoadCookieHeader(context.Background(), Options{ TargetURL: "https://example.invalid/", @@ -75,9 +63,7 @@ func TestLoadCookieHeader_StructuredError_WithStubRunner(t *testing.T) { } cacheDir := t.TempDir() - p := filepath.Join(cacheDir, "node_modules", "chrome-cookies-secure", "package.json") - _ = os.MkdirAll(filepath.Dir(p), 0o755) - _ = os.WriteFile(p, []byte("{}"), 0o600) + seedNpmProject(t, cacheDir) _, err := LoadCookieHeader(context.Background(), Options{ TargetURL: "https://example.invalid/", @@ -102,9 +88,7 @@ exit 0 withPATH(t, fakeBin) cacheDir := t.TempDir() - p := filepath.Join(cacheDir, "node_modules", "chrome-cookies-secure", "package.json") - _ = os.MkdirAll(filepath.Dir(p), 0o755) - _ = os.WriteFile(p, []byte("{}"), 0o600) + seedNpmProject(t, cacheDir) res, err := LoadCookieHeader(context.Background(), Options{ TargetURL: "https://example.invalid/", @@ -133,9 +117,7 @@ exit 2 withPATH(t, fakeBin) cacheDir := t.TempDir() - p := filepath.Join(cacheDir, "node_modules", "chrome-cookies-secure", "package.json") - _ = os.MkdirAll(filepath.Dir(p), 0o755) - _ = os.WriteFile(p, []byte("{}"), 0o600) + seedNpmProject(t, cacheDir) _, err := LoadCookieHeader(context.Background(), Options{ TargetURL: "https://example.invalid/", @@ -163,12 +145,31 @@ func writeExe(t *testing.T, path, content string) { } } +func seedNpmProject(t *testing.T, dir string) { + t.Helper() + p := filepath.Join(dir, "node_modules", "chrome-cookies-secure", "package.json") + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(p, []byte("{}"), 0o600); err != nil { + t.Fatalf("write module package: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(npmPackageJSON+"\n"), 0o600); err != nil { + t.Fatalf("write root package: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, npmStateFile), []byte(npmPackageJSON+"\n"), 0o600); err != nil { + t.Fatalf("write dependency state: %v", err) + } +} + func TestEnsureNpmProject_Installs_WithFakeNpm(t *testing.T) { fakeBin := t.TempDir() writeExe(t, filepath.Join(fakeBin, "npm"), `#!/bin/sh set -e -mkdir -p node_modules/chrome-cookies-secure -echo '{}' > node_modules/chrome-cookies-secure/package.json +mkdir -p node_modules/chrome-cookies-secure node_modules/tar node_modules/@tootallnate/once +echo '{"name":"chrome-cookies-secure","version":"3.0.2"}' > node_modules/chrome-cookies-secure/package.json +echo '{"name":"tar","version":"7.5.20"}' > node_modules/tar/package.json +echo '{"name":"@tootallnate/once","version":"2.0.1"}' > node_modules/@tootallnate/once/package.json exit 0 `) withPATH(t, fakeBin) @@ -183,6 +184,74 @@ exit 0 if _, err := os.Stat(filepath.Join(dir, "node_modules", "chrome-cookies-secure", "package.json")); err != nil { t.Fatalf("expected node_modules: %v", err) } + pkg, err := os.ReadFile(filepath.Join(dir, "package.json")) + if err != nil { + t.Fatalf("read package.json: %v", err) + } + for _, want := range []string{`"chrome-cookies-secure":"3.0.2"`, `"tar":"7.5.20"`, `"@tootallnate/once":"2.0.1"`} { + if !strings.Contains(string(pkg), want) { + t.Errorf("package.json missing %s: %s", want, pkg) + } + } + if _, err := os.Stat(filepath.Join(dir, npmStateFile)); err != nil { + t.Fatalf("expected dependency state: %v", err) + } +} + +func TestEnsureNpmProject_RefreshesStaleConfig(t *testing.T) { + fakeBin := t.TempDir() + writeExe(t, filepath.Join(fakeBin, "npm"), `#!/bin/sh +set -e +touch npm-invoked +mkdir -p node_modules/chrome-cookies-secure node_modules/tar node_modules/@tootallnate/once +echo '{"name":"chrome-cookies-secure","version":"3.0.2"}' > node_modules/chrome-cookies-secure/package.json +echo '{"name":"tar","version":"7.5.20"}' > node_modules/tar/package.json +echo '{"name":"@tootallnate/once","version":"2.0.1"}' > node_modules/@tootallnate/once/package.json +exit 0 +`) + withPATH(t, fakeBin) + + dir := t.TempDir() + p := filepath.Join(dir, "node_modules", "chrome-cookies-secure", "package.json") + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(p, []byte("{}"), 0o600); err != nil { + t.Fatalf("write module package: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"dependencies":{"chrome-cookies-secure":"3.0.0"}}`), 0o600); err != nil { + t.Fatalf("write stale package: %v", err) + } + + if err := ensureNpmProject(context.Background(), dir, nil); err != nil { + t.Fatalf("ensureNpmProject: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "npm-invoked")); err != nil { + t.Fatalf("expected npm reinstall: %v", err) + } +} + +func TestVerifyInstalledNpmDependencies_RejectsStaleOverride(t *testing.T) { + dir := t.TempDir() + packages := map[string]string{ + "chrome-cookies-secure": `{"name":"chrome-cookies-secure","version":"3.0.2"}`, + "tar": `{"name":"tar","version":"7.5.15"}`, + "@tootallnate/once": `{"name":"@tootallnate/once","version":"2.0.1"}`, + } + for name, metadata := range packages { + p := filepath.Join(dir, "node_modules", filepath.FromSlash(name), "package.json") + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", name, err) + } + if err := os.WriteFile(p, []byte(metadata), 0o600); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + + err := verifyInstalledNpmDependencies(dir) + if err == nil || !strings.Contains(err.Error(), "installed tar version 7.5.15, want 7.5.20") { + t.Fatalf("unexpected error: %v", err) + } } func TestRunScriptReal_NodeMissing(t *testing.T) {