Skip to content

fix(new): guard unsupported MxToolset output paths - #865

Open
GG-O-BP wants to merge 1 commit into
mendixlabs:mainfrom
GG-O-BP:fix/825-long-output-path
Open

fix(new): guard unsupported MxToolset output paths#865
GG-O-BP wants to merge 1 commit into
mendixlabs:mainfrom
GG-O-BP:fix/825-long-output-path

Conversation

@GG-O-BP

@GG-O-BP GG-O-BP commented Aug 10, 2026

Copy link
Copy Markdown

Closes #825

Summary

  • inspect the blank-project archive from the exact resolved MxToolset version before extraction
  • count the projected destination in UTF-16 code units and reject paths above MxToolset's 259-unit maximum with a shorter --output-dir recommendation
  • roll back partial output after create or post-create validation failures while preserving a pre-existing empty output directory

Why this uses the fallback

I tested the short-path staging approach proposed in the issue before implementing the guard:

Output directory Longest template destination update-widgets check MxBuild deploy
100 UTF-16 units 282 pass pass pass
180 UTF-16 units 362 pass pass PathTooLongException while exporting widgets

A subst alias also lets the tested operations use a shorter spelling, but that would require wrapping every later MxToolset invocation and make behavior depend on the alias. Staging therefore defers rather than solves the constraint for sufficiently deep projects, matching the fallback condition described in the issue comment.

Implementation

mxcli new now:

  1. resolves the exact requested MxToolset version;
  2. finds the largest embedded ZIP with a root .mpr in Mendix.Modeler.Core.dll and measures its longest entry;
  3. includes the requested <app-name>.mpr in the calculation when it is longer;
  4. rejects a projected destination above 259 UTF-16 units before creating the output; and
  5. removes output created by a failed invocation, or empties but preserves a pre-existing empty output directory.

The output identity is checked before rollback so a replaced directory is never recursively removed. Windows keeps explicit junction/subst spellings, while Unix resolves an existing symlink prefix to match the path MxToolset observes after chdir.

Verification

Automated checks:

  • go test ./... -count=1
  • go vet ./cmd/mxcli
  • GOOS=windows GOARCH=amd64 go test -c ./cmd/mxcli
  • git diff --check
  • installed-template scan against Mendix 10.24.9 and 11.12.2; both report the current 181-unit longest relative path

Focused tests cover:

  • the 259/260 boundary and the actionable error
  • UTF-16 surrogate-pair counting
  • version-specific embedded archive discovery
  • new and pre-existing empty output directories
  • rollback after partial extraction and after a missing .mpr
  • preservation of an existing output symlink
  • rejection of non-empty output before creation

Native Windows/MxToolset matrix:

  • Windows 11 Home 10.0.26200
  • Mendix/MxToolset 11.12.2
  • output directory 77 units / longest destination 259: mx create-project exit 0
  • output directory 78 units / longest destination 260: exit 1 with PathTooLongException, no .mpr, partial files before this fix
  • registry LongPathsEnabled 0 and 1 crossed with process longPathAware OFF and ON: all four 260-unit runs exited 1 with PathTooLongException
  • OFF used the shipped mx.exe; ON used the signed system dotnet.exe manifest to invoke the same mx.dll
  • LongPathsEnabled was restored to its original value (0) after the matrix

The host's Application Control blocks unsigned Go test executables, so the full Go suite ran under Rocky Linux/WSL; native Windows behavior was exercised directly with the installed MxToolset, and the package was separately compiled for windows/amd64.

@ako

ako commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Reviewed by fetching the PR head and building it locally (make grammar + embed sync). The new tests pass, go vet and gofmt clean.

The rollback machinery is careful and genuinely well covered — symlinked output, pre-existing empty output, created-parent cleanup and a SameFile guard all have tests. The risk in this PR is concentrated in the new preflight, which is a heuristic byte-scan over a third-party binary that now hard-gates the whole command.


1. Template introspection failure aborts mxcli new entirely

cmd/mxcli/cmd_new_project.go:71

longestRelativePath, err := longestBlankProjectTemplatePath(mxPath)
if err != nil {
	return fmt.Errorf("inspecting the Mendix %s blank project template: %w", mendixVersion, err)
}
Any failure to introspect Mendix.Modeler.Core.dllnot a sibling of mx, a compressed or renamed resource, ZIP64 sentinels, a future layout changeexits 1 before creating anything, including for output paths that are perfectly fine. With the DLL absent the command dies with reading .../Mendix.Modeler.Core.dll: no such file or directory.

This inverts the risk the feature is meant to reduce: an advisory heuristic over someone else's binary should not be able to block a command it is only advising. Degrading to "skip the length check" on introspection failure keeps mxcli new working when Mendix moves something.

Worth noting the only test against a real MxToolset is opt-in via MXCLI_TEST_MX, so CI won't catch that layout change either.

2. The length error prints a budget the typed path already satisfies
[cmd/mxcli/cmd_new_project.go:88](https://github.com/ako/mxcli/blob/claude/widget-sync-modelsdk-rawunits/cmd/mxcli/cmd_new_project.go#L88)

The check measures the symlink-resolved path (mxToolsetOutputPath(outputDir)) but the message reports the unresolved outputDir. A 74-unit --output-dir behind a symlink is rejected with "Use a shorter --output-dir (at most 108 UTF-16 code units)"advice the user has already met and cannot act on. Reporting the resolved path, or both, makes it followable.

3. maxOutputLength is unclamped and can go negative
[cmd/mxcli/cmd_new_project.go:88](https://github.com/ako/mxcli/blob/claude/widget-sync-modelsdk-rawunits/cmd/mxcli/cmd_new_project.go#L88)

maxOutputLength := mxToolsetMaxPathUTF16Units - utf16PathLen(filepath.FromSlash(longestRelativePath)) - 1
With a template path258 units this renders as "at most -42 UTF-16 code units for this version". A floor at 0 plus a distinct message for "no output directory can satisfy this template" would be honest about the situation.

4. Whole DLL read into memory and scanned with no early exit
[cmd/mxcli/cmd_new_project.go:140](https://github.com/ako/mxcli/blob/claude/widget-sync-modelsdk-rawunits/cmd/mxcli/cmd_new_project.go#L140) — os.ReadFile on the entire Mendix.Modeler.Core.dll, then a scan for every EOCD signature, to compute a single integer. Performance only, but this now runs on every mxcli new.

Not a finding, recorded so it isn't re-raised. I looked hard at whether rollback could os.RemoveAll a directory mxcli did not create — state.preexisting is decided in inspectNewProjectOutput before ensureNewProjectOutputDirectory runs, which looks like a TOCTOU window. It holds up: ensureNewProjectOutputDirectory uses os.Mkdir per path element rather than MkdirAll, and fails hard on any error including EEXIST, unwinding what it created. So a directory appearing in that window makes the function error out rather than reaching rollback with a stale state. Deriving preexisting from createdDirs would be tidier, but there's no data-loss bug here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

mxcli new fails with PathTooLongException for deep output paths and leaves partial output

2 participants