From 8bcb72827c7163443fdfab8a9f66e7072b7f3531 Mon Sep 17 00:00:00 2001 From: MendixMau Date: Fri, 6 Nov 2026 13:55:49 +0100 Subject: [PATCH] fix(run): a copied app folder's run state, handshakes and stored secrets .mxcli/run-state.json, run-local.json and test-endpoint.json record the project they were written for but nothing compared it, so a copy refused to start, run stop signalled the original's process, and test --attach used the original's app. All readers now ignore a file written for another project (SameProject: symlinks resolved, case-folded on macOS and Windows). --constant values, the password flags and the DatabasePassword and MicroflowConstants runtime settings are no longer written to the stored arguments, and the dev-loop handshake no longer carries MicroflowConstants (ApplyConstants replaces them). run restart refuses, saying why and naming the alternatives, when the stored arguments held a redacted secret. Fixes mendixlabs/mxcli#1764 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + cmd/mxcli/cmd_run.go | 7 +- cmd/mxcli/cmd_run_lifecycle.go | 31 +++- cmd/mxcli/devloop_handshake.go | 25 +++ cmd/mxcli/docker/runstate.go | 26 ++- cmd/mxcli/run_foreign_state_test.go | 233 +++++++++++++++++++++++++ cmd/mxcli/run_redact.go | 131 ++++++++++++++ cmd/mxcli/testrunner/handshake.go | 9 + cmd/mxcli/testrunner/handshake_test.go | 33 ++++ internal/pathutil/sameproject.go | 57 ++++++ internal/pathutil/sameproject_test.go | 70 ++++++++ 11 files changed, 619 insertions(+), 4 deletions(-) create mode 100644 cmd/mxcli/run_foreign_state_test.go create mode 100644 cmd/mxcli/run_redact.go create mode 100644 internal/pathutil/sameproject.go create mode 100644 internal/pathutil/sameproject_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index fcc12d5d5f..ec9d557887 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **`validation feedback` on an attribute named `Error` parses** (ako/mxcli#1913, mendixlabs/mxcli#1720) — `validation feedback $E/Error message 'x'`, which is what `describe` writes for such an attribute, failed with "missing MESSAGE" because `Error message` was read as the `error message` clause keyword. After a `/` or `.` the word is now always a member name, in any case and anywhere an attribute path appears; the `error message` clause itself is unchanged. - **`dateFormat: custom` on a dynamic-text parameter is stored as Custom** (ako/mxcli#1864, mendixlabs/mxcli#1706) — `check` accepts a parameter's `dateFormat` and `enumFormat` in any case, but the writer stored the value as written, and `"custom"` is not a value Mendix knows, so the runtime showed the default format. The values are now stored in Mendix's spelling (`Custom`, `DateTime`, `Image`) on dynamic text and on Data grid 2 column templates. - **A doc comment above `alter entity … add attribute` is stored on the attribute** (ako/mxcli#1863, mendixlabs/mxcli#1705) — `/** Why */` written above the statement was read by nothing: exec reported "Added attribute" and the attribute had no documentation, with only a later `check` warning MDL089. It now documents the attribute the statement adds, as it does above a `create entity` attribute, for `alter entities` too. When the statement adds two attributes, or the attribute has its own comment, the comment is still not stored and `check` still warns. +- **A copied app folder no longer inherits the original's run state, and run state no longer stores secrets** (mendixlabs/mxcli#1764) — `.mxcli/run-state.json`, `run-local.json` and `test-endpoint.json` record the project they were written for, but nothing compared it, so in a copy of a running app `mxcli run` refused to start and `mxcli run stop` signalled the original's process, and `mxcli test --attach` talked to the original's app. All three are now ignored when they name another project (symlinks resolved, case-folded on macOS and Windows); `run status` and `run stop` say that they ignored one. `--constant` values, the password flags and the `DatabasePassword` and `MicroflowConstants` runtime settings are no longer stored in `run-state.json`, and the dev-loop handshake no longer holds the resolved constants. The handshake file `run-local.json` still holds the database password (re-applying constants needs it); it is written owner-only (0600). **Behaviour change:** a run started with `--db-password` (or another inline secret) cannot be `run restart`ed; `run restart` says so and names the alternatives (the `PGPASSWORD` environment variable, or re-running `mxcli run` with the password) instead of starting the app with a placeholder. A run recorded from a devcontainer or WSL path is treated as another folder by the host side, so its state is ignored there. ## [0.30.0] - 2026-11-02 diff --git a/cmd/mxcli/cmd_run.go b/cmd/mxcli/cmd_run.go index a4fe094279..614b4c10bd 100644 --- a/cmd/mxcli/cmd_run.go +++ b/cmd/mxcli/cmd_run.go @@ -336,7 +336,7 @@ Examples: AppPort: info.AppPort, AdminPort: info.AdminPort, AdminPass: info.AdminPass, - BootConfig: info.BootConfig, + BootConfig: bootConfigForHandshake(info.BootConfig), Started: time.Now(), }); err != nil { fmt.Fprintf(os.Stderr, "Warning: could not publish the dev-loop handshake: %v\n", err) @@ -359,8 +359,11 @@ Examples: if detachedChild { logPath = docker.RunLogPath(projectPath) } + // The stored arguments are for `run restart`, not a copy of the + // secrets: they stay out of the file (see redactArgs). + storedArgs, _ := redactArgs(detachArgs(os.Args[1:], projectPath)) state = docker.NewRunStateRecorder(projectPath, os.Getpid(), detachedChild, watch, - detachArgs(os.Args[1:], projectPath), logPath) + storedArgs, logPath) state.Warn = func(err error) { fmt.Fprintf(os.Stderr, "Warning: could not publish the run state: %v\n", err) } diff --git a/cmd/mxcli/cmd_run_lifecycle.go b/cmd/mxcli/cmd_run_lifecycle.go index 3dce97f499..b771aabc97 100644 --- a/cmd/mxcli/cmd_run_lifecycle.go +++ b/cmd/mxcli/cmd_run_lifecycle.go @@ -154,7 +154,11 @@ start it again in the background with the arguments it was started with (as The arguments come from the project's run state, so this also restarts a run that has already stopped or crashed. To change a flag, stop it and start a new -one: mxcli run stop -p app.mpr && mxcli run --local --watch --detach -p app.mpr …`, +one: mxcli run stop -p app.mpr && mxcli run --local --watch --detach -p app.mpr … + +Secrets given on the command line (--constant values, --db-password, +--screenshot-password, --hub-secret, --debug-pass) are not stored with the run, so +a run started with them cannot be restarted: restart says so and changes nothing.`, Example: ` mxcli run restart -p app.mpr`, Run: func(cmd *cobra.Command, args []string) { projectPath := lifecycleProject(cmd) @@ -163,11 +167,23 @@ one: mxcli run stop -p app.mpr && mxcli run --local --watch --detach -p app.mpr fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(runExitFailed) } + noteForeignRunState(projectPath, os.Stderr) if st == nil || len(st.Args) == 0 { fmt.Fprintf(os.Stderr, "nothing to restart: no run of this project is recorded\n"+ " start one: mxcli run --local --watch --detach -p %s\n", projectPath) os.Exit(runExitNotRunning) } + // Secrets given on the command line are not stored with the run, so a + // restart would hand the app the placeholder instead of the value. Say so + // before stopping anything. + if hasRedactedArgs(st.Args) { + fmt.Fprintf(os.Stderr, "cannot restart: this run was started with secrets on the command line "+ + "(--constant values, --db-password, ...), and they are not stored.\n"+ + " Either set the database password in the PGPASSWORD environment variable, or stop the run and\n"+ + " start it again with the secrets: mxcli run stop -p %s && mxcli run --local --detach -p %s ...\n", + projectPath, projectPath) + os.Exit(runExitFailed) + } if code := runStop(projectPath, os.Stdout); code != runExitOK { os.Exit(code) } @@ -218,6 +234,17 @@ func detachArgs(args []string, projectPath string) []string { return append(out, "-p", projectPath) } +// noteForeignRunState says so when the run state in this project's .mxcli/ was +// written for another project, which a copied folder brings along. Every reader +// treats such a state as no run at all (docker.ReadRunState), so a command +// that goes on to report "not running" is not leaving the user guessing why. +func noteForeignRunState(projectPath string, out io.Writer) { + if st := docker.ForeignRunState(projectPath); st != nil { + fmt.Fprintf(out, "note: .mxcli/run-state.json here belongs to the run of %s (pid %d), not this project; ignoring it\n", + st.Project, st.PID) + } +} + // refuseIfRunning returns an error when a live run already serves the project // (other than this process). func refuseIfRunning(projectPath string) error { @@ -375,6 +402,7 @@ func printLogWarnings(out io.Writer, logPath string, max int) { // --- status ------------------------------------------------------------ func runStatus(projectPath string, out io.Writer) int { + noteForeignRunState(projectPath, out) st, err := docker.ReadRunState(projectPath) if err != nil { fmt.Fprintf(out, "unknown: %v\n", err) @@ -628,6 +656,7 @@ func runWaitReady(projectPath string, timeout time.Duration, out io.Writer) int // --- stop -------------------------------------------------------------- func runStop(projectPath string, out io.Writer) int { + noteForeignRunState(projectPath, out) st, err := docker.ReadRunState(projectPath) if err != nil { fmt.Fprintf(out, "failed: %v\n", err) diff --git a/cmd/mxcli/devloop_handshake.go b/cmd/mxcli/devloop_handshake.go index f0dc17d094..29c7928c4b 100644 --- a/cmd/mxcli/devloop_handshake.go +++ b/cmd/mxcli/devloop_handshake.go @@ -10,6 +10,7 @@ import ( "path/filepath" "time" + "github.com/mendixlabs/mxcli/internal/pathutil" "github.com/mendixlabs/mxcli/internal/procalive" ) @@ -48,6 +49,23 @@ type devLoopHandshake struct { Started time.Time `json:"started"` } +// bootConfigForHandshake is the boot payload without MicroflowConstants. Every +// resolved constant is in there, secrets included, and nothing reads them back: +// docker.ApplyConstants replaces that key with the freshly resolved chain +// before it re-sends the payload. +func bootConfigForHandshake(boot map[string]any) map[string]any { + if _, ok := boot["MicroflowConstants"]; !ok { + return boot + } + out := make(map[string]any, len(boot)) + for k, v := range boot { + if k != "MicroflowConstants" { + out[k] = v + } + } + return out +} + func devLoopHandshakePath(projectPath string) string { return filepath.Join(filepath.Dir(projectPath), ".mxcli", devLoopHandshakeName) } @@ -121,6 +139,13 @@ func readDevLoopHandshake(projectPath string) (devLoopHandshake, error) { if err := json.Unmarshal(body, &h); err != nil { return h, fmt.Errorf("%s is not valid JSON: %w", path, err) } + // A copied app folder carries the original's handshake. Its ports and + // password belong to the original's runtime, so it is no run of this project. + if !pathutil.SameProject(h.Project, projectPath) { + return devLoopHandshake{}, fmt.Errorf("%w\n"+ + " (%s was left by the run of %s, not this project; ignoring it)%s", + errNoDevLoopHandshake, path, h.Project, runStatusNote(projectPath)) + } if !procalive.Alive(h.PID) { return h, fmt.Errorf("%s refers to process %d, which is %w\n"+ " (the dev loop was stopped without cleaning up; start a new one)%s", diff --git a/cmd/mxcli/docker/runstate.go b/cmd/mxcli/docker/runstate.go index e19d058329..6213cb9be9 100644 --- a/cmd/mxcli/docker/runstate.go +++ b/cmd/mxcli/docker/runstate.go @@ -11,6 +11,8 @@ import ( "strings" "sync" "time" + + "github.com/mendixlabs/mxcli/internal/pathutil" ) // runstate.go is the contract between a running `mxcli run --local` and the @@ -119,8 +121,30 @@ type RunState struct { Updated time.Time `json:"updated"` } -// ReadRunState reads a project's state file. A missing file is (nil, nil). +// ReadRunState reads a project's state file. A missing file is (nil, nil), and +// so is one that was written for another project: copying an app folder copies +// .mxcli/ with it, and a state that names the original's pid must neither block +// the copy nor be acted on (`run stop` would signal the original's process). +// ForeignRunState says whether that is what was skipped. func ReadRunState(projectPath string) (*RunState, error) { + st, err := readRunStateFile(projectPath) + if st != nil && !pathutil.SameProject(st.Project, projectPath) { + return nil, nil + } + return st, err +} + +// ForeignRunState returns the state in projectPath's .mxcli/ when it was +// written for a different project, so a command can say that it is ignoring it. +func ForeignRunState(projectPath string) *RunState { + st, err := readRunStateFile(projectPath) + if err != nil || st == nil || pathutil.SameProject(st.Project, projectPath) { + return nil + } + return st +} + +func readRunStateFile(projectPath string) (*RunState, error) { body, err := os.ReadFile(RunStatePath(projectPath)) if os.IsNotExist(err) { return nil, nil diff --git a/cmd/mxcli/run_foreign_state_test.go b/cmd/mxcli/run_foreign_state_test.go new file mode 100644 index 0000000000..e3e4f45f9a --- /dev/null +++ b/cmd/mxcli/run_foreign_state_test.go @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Copying an app folder copies .mxcli/ with it. These tests copy a project the +// way `cp -R` does and prove the copy neither inherits nor acts on the +// original's run state or handshake, and that no secret is stored. +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/cmd/mxcli/docker" +) + +// copyProject copies the project folder, .mxcli/ included, and returns the +// copy's .mpr. +func copyProject(t *testing.T, mpr string) string { + t.Helper() + dst := filepath.Join(t.TempDir(), "copy") + src := filepath.Dir(mpr) + err := filepath.Walk(src, func(p string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(src, p) + target := filepath.Join(dst, rel) + if info.IsDir() { + return os.MkdirAll(target, 0o755) + } + body, err := os.ReadFile(p) + if err != nil { + return err + } + return os.WriteFile(target, body, info.Mode().Perm()) + }) + if err != nil { + t.Fatal(err) + } + return filepath.Join(dst, filepath.Base(mpr)) +} + +func TestRefuseIfRunning_ForeignProjectIsNotBlocking(t *testing.T) { + orig := scratchProject(t) + live := startSleeper(t) + _ = docker.WriteRunState(orig, &docker.RunState{ + Project: orig, PID: live, Phase: docker.PhaseRunning, URL: "http://127.0.0.1:8080/", + }) + cp := copyProject(t, orig) + + if err := refuseIfRunning(orig); err == nil { + t.Fatal("the original's own live run no longer blocks a second start") + } + if err := refuseIfRunning(cp); err != nil { + t.Errorf("the copy was blocked by the original's run: %v", err) + } +} + +func TestRefuseIfRunning_SymlinkedPathIsTheSameProject(t *testing.T) { + orig := scratchProject(t) + live := startSleeper(t) + _ = docker.WriteRunState(orig, &docker.RunState{Project: orig, PID: live, Phase: docker.PhaseRunning}) + link := filepath.Join(t.TempDir(), "link") + if err := os.Symlink(filepath.Dir(orig), link); err != nil { + t.Skip("cannot create a symlink here:", err) + } + if err := refuseIfRunning(filepath.Join(link, "App.mpr")); err == nil { + t.Error("a symlinked path to the project was taken for another project, so a second run could start") + } +} + +func TestRefuseIfRunning_StateWithoutProjectStillBlocks(t *testing.T) { + mpr := scratchProject(t) + live := startSleeper(t) + _ = docker.WriteRunState(mpr, &docker.RunState{PID: live, Phase: docker.PhaseRunning}) + if err := refuseIfRunning(mpr); err == nil { + t.Error("a state written before the project was recorded must still block") + } +} + +func TestRunStop_ForeignStateDoesNotSignal(t *testing.T) { + orig := scratchProject(t) + live := startSleeper(t) + _ = docker.WriteRunState(orig, &docker.RunState{Project: orig, PID: live, Phase: docker.PhaseRunning}) + cp := copyProject(t, orig) + + var out bytes.Buffer + if code := runStop(cp, &out); code != runExitOK { + t.Fatalf("exit %d, output %q", code, out.String()) + } + if !docker.PidAlive(live) { + t.Fatal("run stop in the copy terminated the original's process") + } + if !strings.Contains(out.String(), "belongs to the run of "+orig) || + !strings.Contains(out.String(), "stopped (nothing was running)") { + t.Errorf("the output does not say the state was ignored:\n%s", out.String()) + } + if st, _ := docker.ReadRunState(orig); st == nil || st.Phase != docker.PhaseRunning { + t.Errorf("the original's state was changed: %+v", st) + } +} + +func TestRunWaitAndStatus_ForeignStateIsNotRunning(t *testing.T) { + fastPolling(t) + orig := scratchProject(t) + live := startSleeper(t) + _ = docker.WriteRunState(orig, &docker.RunState{Project: orig, PID: live, Phase: docker.PhaseRunning, Watch: true}) + cp := copyProject(t, orig) + + var out bytes.Buffer + if code := runWait(cp, 0, 0, &out); code != runExitNotRunning { + t.Errorf("run wait in the copy: exit %d, output %q", code, out.String()) + } + out.Reset() + if code := runStatus(cp, &out); code != runExitNotRunning { + t.Errorf("run status in the copy: exit %d, output %q", code, out.String()) + } + if !strings.Contains(out.String(), "ignoring it") { + t.Errorf("run status does not say it ignored the copied state:\n%s", out.String()) + } +} + +func TestReadDevLoopHandshake_ForeignProject(t *testing.T) { + orig := handshakeProject(t) + if err := writeDevLoopHandshake(orig, devLoopHandshake{Project: orig, PID: os.Getpid(), AdminPort: 8090}); err != nil { + t.Fatal(err) + } + cp := copyProject(t, orig) + if _, err := readDevLoopHandshake(orig); err != nil { + t.Fatalf("the original's own handshake was refused: %v", err) + } + _, err := readDevLoopHandshake(cp) + if err == nil { + t.Fatal("the copy accepted the original's handshake") + } + if !strings.Contains(err.Error(), orig) || !strings.Contains(err.Error(), "ignoring") { + t.Errorf("the error does not name the other project: %v", err) + } +} + +func TestRedactArgs(t *testing.T) { + in := []string{"run", "--local", "--constant", "A.Key=sekret", "--constant=B.Key=top=secret", + "--constant", "C.Key=@env:TOKEN", "--db-password", "p4ss", "--hub-secret=u:s", "--watch", + "--app-port", "8080", "-p", "/x/App.mpr"} + got, changed := redactArgs(in) + want := []string{"run", "--local", "--constant", "A.Key=" + redactedValue, + "--constant=B.Key=" + redactedValue, "--constant", "C.Key=@env:TOKEN", + "--db-password", redactedValue, "--hub-secret=" + redactedValue, "--watch", + "--app-port", "8080", "-p", "/x/App.mpr"} + if !changed || !reflect.DeepEqual(got, want) { + t.Errorf("redactArgs:\n got %q\n want %q (changed=%v)", got, want, changed) + } + if in[3] != "A.Key=sekret" { + t.Error("redactArgs modified its input, which is the child's real command line") + } + if _, changed := redactArgs([]string{"run", "--local", "--constant", "C.Key=@file:/x/tok"}); changed { + t.Error("an indirection was treated as a secret") + } + if !hasRedactedArgs(got) { + t.Error("hasRedactedArgs does not see the placeholder") + } + if hasRedactedArgs([]string{"run", "--local", "--constant", "C.Key=@env:TOKEN"}) { + t.Error("hasRedactedArgs fired without a placeholder") + } +} + +func TestRedactArgsRuntimeSettingsAndEmpty(t *testing.T) { + in := []string{"run", "--runtime-setting", "DatabasePassword=pw", "--runtime-setting=MicroflowConstants={\"A.B\":\"x\"}", + "--runtime-setting", "ScheduledEventExecution=NONE", "--runtime-setting=DatabasePassword=", "--db-password=", "--db-password", ""} + got, changed := redactArgs(in) + want := []string{"run", "--runtime-setting", "DatabasePassword=" + redactedValue, + "--runtime-setting=MicroflowConstants=" + redactedValue, + "--runtime-setting", "ScheduledEventExecution=NONE", "--runtime-setting=DatabasePassword=", "--db-password=", "--db-password", ""} + if !changed || !reflect.DeepEqual(got, want) { + t.Errorf("redactArgs:\n got %q\n want %q", got, want) + } + if _, c := redactArgs([]string{"run", "--db-password=", "--runtime-setting", "DatabasePassword="}); c || hasRedactedArgs(got[len(got)-3:]) { + t.Error("an empty secret was treated as redacted") + } +} + +func TestRunStateDoesNotContainSecrets(t *testing.T) { + mpr := scratchProject(t) + args, _ := redactArgs(detachArgs([]string{"run", "--local", "--constant", "A.Key=sekret-value", "--db-password=p4ss-word"}, mpr)) + if err := docker.WriteRunState(mpr, &docker.RunState{Project: mpr, PID: 1, Args: args}); err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(docker.RunStatePath(mpr)) + if err != nil { + t.Fatal(err) + } + for _, secret := range []string{"sekret-value", "p4ss-word"} { + if strings.Contains(string(body), secret) { + t.Errorf("run-state.json contains %q:\n%s", secret, body) + } + } + if !strings.Contains(string(body), "A.Key=") { + t.Errorf("the constant name was lost:\n%s", body) + } +} + +func TestHandshakeDoesNotCarryConstants(t *testing.T) { + boot := map[string]any{ + "DatabaseName": "app", + "MicroflowConstants": map[string]string{"A.Key": "sekret-value"}, + } + got := bootConfigForHandshake(boot) + if _, ok := got["MicroflowConstants"]; ok { + t.Error("MicroflowConstants is still in the handshake's boot config") + } + if got["DatabaseName"] != "app" { + t.Errorf("the rest of the boot config was lost: %v", got) + } + if _, ok := boot["MicroflowConstants"]; !ok { + t.Error("the runtime's own boot config was modified") + } + p := handshakeProject(t) + if err := writeDevLoopHandshake(p, devLoopHandshake{Project: p, PID: os.Getpid(), BootConfig: got}); err != nil { + t.Fatal(err) + } + body, _ := os.ReadFile(devLoopHandshakePath(p)) + if strings.Contains(string(body), "sekret-value") { + t.Errorf("run-local.json contains a constant value:\n%s", body) + } + var h devLoopHandshake + if err := json.Unmarshal(body, &h); err != nil || len(h.BootConfig) == 0 { + t.Errorf("the handshake no longer carries a boot config to re-send: %v %v", err, h.BootConfig) + } +} diff --git a/cmd/mxcli/run_redact.go b/cmd/mxcli/run_redact.go new file mode 100644 index 0000000000..1df7bf6fd0 --- /dev/null +++ b/cmd/mxcli/run_redact.go @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import "strings" + +// redactedValue stands in for a secret in the arguments stored in run-state.json. +const redactedValue = "" + +// secretRunFlags are the `run` flags whose value is a credential. The state file +// is 0600, but it sits in .mxcli/ next to the project and travels with a copy of +// the folder, a sync client or an archive. +var secretRunFlags = map[string]bool{ + "--db-password": true, + "--db-admin-password": true, + "--screenshot-password": true, + "--hub-secret": true, + "--debug-pass": true, +} + +// redactArgs returns args with the value of every secret flag replaced by +// redactedValue, and whether it replaced any. For --constant NAME=VALUE the name +// is kept and the value replaced. A value written as an indirection +// (@env:VAR, @file:path) is not a secret, only a pointer to one, and is kept so +// `run restart` can resolve it again. +func redactArgs(args []string) ([]string, bool) { + out := make([]string, 0, len(args)) + changed := false + for i := 0; i < len(args); i++ { + a := args[i] + flag, val, inline := a, "", false + if k := strings.Index(a, "="); strings.HasPrefix(a, "--") && k > 0 { + flag, val, inline = a[:k], a[k+1:], true + } + switch { + case secretRunFlags[flag]: + if inline { + // An empty value holds no secret; keep it empty so a restart + // is not refused for a placeholder that stands for nothing. + if val == "" { + out = append(out, a) + continue + } + out = append(out, flag+"="+redactedValue) + changed = true + continue + } + out = append(out, a) + if i+1 < len(args) { + i++ + if args[i] == "" { + out = append(out, "") + } else { + out = append(out, redactedValue) + changed = true + } + } + case flag == "--runtime-setting": + if !inline { + out = append(out, a) + if i+1 < len(args) { + i++ + v, c := redactRuntimeSetting(args[i]) + out = append(out, v) + changed = changed || c + } + continue + } + v, c := redactRuntimeSetting(val) + out = append(out, flag+"="+v) + changed = changed || c + case flag == "--constant": + if !inline { + out = append(out, a) + if i+1 < len(args) { + i++ + v, c := redactConstant(args[i]) + out = append(out, v) + changed = changed || c + } + continue + } + v, c := redactConstant(val) + out = append(out, flag+"="+v) + changed = changed || c + default: + out = append(out, a) + } + } + return out, changed +} + +// secretRuntimeSettings are the runtime settings whose value is a credential or +// holds constant values. +var secretRuntimeSettings = map[string]bool{ + "DatabasePassword": true, + "MicroflowConstants": true, +} + +// redactRuntimeSetting redacts the value of one Key=Value when Key is a secret setting. +func redactRuntimeSetting(kv string) (string, bool) { + k := strings.Index(kv, "=") + if k < 0 || !secretRuntimeSettings[kv[:k]] || kv[k+1:] == "" { + return kv, false + } + return kv[:k+1] + redactedValue, true +} + +// redactConstant redacts the value of one NAME=VALUE. +func redactConstant(nv string) (string, bool) { + k := strings.Index(nv, "=") + if k < 0 { + return nv, false + } + v := nv[k+1:] + if strings.HasPrefix(v, "@env:") || strings.HasPrefix(v, "@file:") { + return nv, false + } + return nv[:k+1] + redactedValue, true +} + +// hasRedactedArgs reports whether args carry a value redactArgs replaced, so a +// restart that would send the placeholder to the app can say so instead. +func hasRedactedArgs(args []string) bool { + for _, a := range args { + if a == redactedValue || strings.HasSuffix(a, "="+redactedValue) { + return true + } + } + return false +} diff --git a/cmd/mxcli/testrunner/handshake.go b/cmd/mxcli/testrunner/handshake.go index 54bb73fc00..8682cd3c62 100644 --- a/cmd/mxcli/testrunner/handshake.go +++ b/cmd/mxcli/testrunner/handshake.go @@ -9,6 +9,7 @@ import ( "path/filepath" "time" + "github.com/mendixlabs/mxcli/internal/pathutil" "github.com/mendixlabs/mxcli/internal/procalive" ) @@ -103,6 +104,14 @@ func ReadHandshake(projectPath string) (*Handshake, error) { if err := json.Unmarshal(body, &h); err != nil { return nil, fmt.Errorf("%s is not readable as a handshake: %w", path, err) } + // A copied app folder carries the original's handshake, with the token and + // ports of the original's runtime. Attaching to it would run the copy's + // tests against another app. + if !pathutil.SameProject(h.Project, projectPath) { + return nil, fmt.Errorf("no test endpoint is being hosted for this project\n"+ + " %s was left by the run of %s, not this one; ignoring it\n"+ + " Start one with: mxcli run --local --test-endpoint -p %s", path, h.Project, projectPath) + } if h.Token == "" || h.AppPort == 0 { return nil, fmt.Errorf("%s is incomplete; stop and restart the hosting 'mxcli run --local --test-endpoint'", path) } diff --git a/cmd/mxcli/testrunner/handshake_test.go b/cmd/mxcli/testrunner/handshake_test.go index 6748770ea6..0d1a25a9c6 100644 --- a/cmd/mxcli/testrunner/handshake_test.go +++ b/cmd/mxcli/testrunner/handshake_test.go @@ -243,3 +243,36 @@ func TestAttachUsesTheAdminPasswordNotTheEndpointToken(t *testing.T) { t.Error("the admin password and the endpoint token are the same value; they are different secrets") } } + +// A copied app folder carries the original's handshake; attaching with it would +// run the copy's tests against another app, with that app's token. +func TestReadHandshake_ForeignProject(t *testing.T) { + orig := tempProject(t) + if err := WriteHandshake(orig, Handshake{ + Project: orig, PID: os.Getpid(), AppPort: 8080, AdminPort: 8090, Token: "tok", Started: time.Now(), + }); err != nil { + t.Fatal(err) + } + cp := filepath.Join(t.TempDir(), "App.mpr") + if err := os.MkdirAll(filepath.Dir(HandshakePath(cp)), 0o755); err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(HandshakePath(orig)) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(HandshakePath(cp), body, 0o600); err != nil { + t.Fatal(err) + } + + if _, err := ReadHandshake(orig); err != nil { + t.Fatalf("the original's own handshake was refused: %v", err) + } + _, err = ReadHandshake(cp) + if err == nil { + t.Fatal("the copy accepted the original's handshake") + } + if !strings.Contains(err.Error(), orig) || !strings.Contains(err.Error(), "ignoring") { + t.Errorf("the error does not name the other project: %v", err) + } +} diff --git a/internal/pathutil/sameproject.go b/internal/pathutil/sameproject.go new file mode 100644 index 0000000000..0136e5d568 --- /dev/null +++ b/internal/pathutil/sameproject.go @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pathutil + +import ( + "os" + "path/filepath" + "runtime" + "strings" +) + +// SameProject reports whether two project paths (.mpr files) name the same +// project directory. +// +// The state files under /.mxcli/ (run-state.json, run-local.json, +// test-endpoint.json) record the project they were written for, and a copy of +// the folder carries them along. A reader that trusts such a file by pid alone +// would refuse to start the copy, or signal the original's process. SameProject +// is the check that tells the two apart. +// +// Symlinks are resolved on both sides (macOS /var vs /private/var, a linked +// workspace), and when both directories exist they are compared by identity, so +// a case-insensitive volume cannot call one directory two. A path that cannot +// be resolved falls back to the cleaned path, compared case-insensitively on +// macOS and Windows. A recorded path that is empty comes from a file written +// before the field existed and is taken to be this project's. +func SameProject(recorded, current string) bool { + if recorded == "" { + return true + } + rd, cd := filepath.Dir(recorded), filepath.Dir(current) + if ri, err := os.Stat(rd); err == nil { + if ci, err := os.Stat(cd); err == nil && os.SameFile(ri, ci) { + return true + } + } + return foldPath(resolvePath(rd)) == foldPath(resolvePath(cd)) +} + +// resolvePath is the absolute, symlink-free, cleaned form of p where the file +// system can say, and the cleaned form otherwise. +func resolvePath(p string) string { + if abs, err := filepath.Abs(p); err == nil { + p = abs + } + if real, err := filepath.EvalSymlinks(p); err == nil { + p = real + } + return filepath.Clean(p) +} + +func foldPath(p string) string { + if runtime.GOOS == "darwin" || runtime.GOOS == "windows" { + return strings.ToLower(p) + } + return p +} diff --git a/internal/pathutil/sameproject_test.go b/internal/pathutil/sameproject_test.go new file mode 100644 index 0000000000..e61bafaa9a --- /dev/null +++ b/internal/pathutil/sameproject_test.go @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pathutil + +import ( + "os" + "path/filepath" + "testing" +) + +func mkProject(t *testing.T, root, name string) string { + t.Helper() + dir := filepath.Join(root, name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + mpr := filepath.Join(dir, "App.mpr") + if err := os.WriteFile(mpr, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + return mpr +} + +func TestSameProject(t *testing.T) { + root := t.TempDir() + orig := mkProject(t, root, "app") + + t.Run("same path", func(t *testing.T) { + if !SameProject(orig, orig) { + t.Error("a path is not the same project as itself") + } + }) + t.Run("copied folder is another project", func(t *testing.T) { + copyMpr := mkProject(t, root, "app-copy") + if SameProject(orig, copyMpr) { + t.Error("a copy of the folder was taken for the original") + } + if SameProject(copyMpr, orig) { + t.Error("the original was taken for the copy") + } + }) + t.Run("symlinked folder is the same project", func(t *testing.T) { + link := filepath.Join(root, "link") + if err := os.Symlink(filepath.Dir(orig), link); err != nil { + t.Skip("cannot create a symlink here:", err) + } + if !SameProject(orig, filepath.Join(link, "App.mpr")) { + t.Error("a symlink to the project folder was taken for another project") + } + }) + t.Run("dot segments", func(t *testing.T) { + if !SameProject(orig, filepath.Join(filepath.Dir(orig), "..", "app", "App.mpr")) { + t.Error("an unclean path was taken for another project") + } + }) + t.Run("legacy file without a project", func(t *testing.T) { + if !SameProject("", orig) { + t.Error("a state written before the project was recorded must still be honoured") + } + }) + t.Run("recorded folder is gone", func(t *testing.T) { + gone := filepath.Join(root, "moved", "App.mpr") + if SameProject(gone, orig) { + t.Error("a vanished folder was taken for this project") + } + if !SameProject(gone, gone) { + t.Error("an unresolvable path is not the same project as itself") + } + }) +}