diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ee0afc53f..27f1243a81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,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. - **`mxcli test` failure line shows the actual value of an enumeration compare** (mendixlabs/mxcli#1763) — `@expect $Paid/Status = Procurement.InvoiceStatus.PAID` failed with only `expected …`, because the actual value was reported only when the operand type was known and an attribute path next to an enumeration value never was. An enumeration value on one side now pins the other, so the line reads `expected $Paid/Status = Procurement.InvoiceStatus.PAID, actual: APPROVED` (the enumeration key, via `toString()`). ## [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") + } + }) +}