From 82f4ea0e2fd3ce1901dcc6e91247e7ccb524f57a Mon Sep 17 00:00:00 2001 From: GG-O-BP Date: Mon, 10 Aug 2026 12:33:24 +0900 Subject: [PATCH] fix(new): guard unsupported MxToolset output paths --- cmd/mxcli/cmd_new.go | 85 +++------ cmd/mxcli/cmd_new_project.go | 352 +++++++++++++++++++++++++++++++++++ cmd/mxcli/cmd_new_test.go | 320 +++++++++++++++++++++++++++++++ 3 files changed, 695 insertions(+), 62 deletions(-) create mode 100644 cmd/mxcli/cmd_new_project.go diff --git a/cmd/mxcli/cmd_new.go b/cmd/mxcli/cmd_new.go index f2a060e3d..4e3ab81e1 100644 --- a/cmd/mxcli/cmd_new.go +++ b/cmd/mxcli/cmd_new.go @@ -11,7 +11,6 @@ import ( "github.com/mendixlabs/mxcli/cmd/mxcli/docker" "github.com/mendixlabs/mxcli/cmd/mxcli/theme" - "github.com/mendixlabs/mxcli/sdk/mpr" "github.com/spf13/cobra" ) @@ -67,9 +66,9 @@ Examples: os.Exit(1) } - // Check if directory already exists and has content - if entries, err := os.ReadDir(absDir); err == nil && len(entries) > 0 { - fmt.Fprintf(os.Stderr, "Error: directory %s already exists and is not empty\n", absDir) + // Reject an unusable output before resolving or downloading MxBuild. + if _, err := inspectNewProjectOutput(absDir); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } @@ -86,71 +85,33 @@ Examples: } os.Exit(1) } - - // Step 2: Create project - fmt.Printf("\nStep 2/6: Creating Mendix project '%s'...\n", appName) - if err := os.MkdirAll(absDir, 0755); err != nil { - fmt.Fprintf(os.Stderr, "Error creating directory: %v\n", err) + if err := validateNewProjectOutputPath(absDir, appName, mendixVersion, mxPath); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } - mxCmd := exec.Command(mxPath, "create-project", "--app-name", appName) - mxCmd.Dir = absDir - mxCmd.Stdout = os.Stdout - mxCmd.Stderr = os.Stderr - docker.PrepareMxCommand(mxCmd) - if err := mxCmd.Run(); err != nil { - fmt.Fprintf(os.Stderr, "Error creating project: %v\n", err) + // Step 2: Create project + fmt.Printf("\nStep 2/6: Creating Mendix project '%s'...\n", appName) + created, err := createMendixProjectWithRollback(absDir, appName, mendixVersion, mxPath, + func(projectDir string) error { + mxCmd := exec.Command(mxPath, "create-project", "--app-name", appName) + mxCmd.Dir = projectDir + mxCmd.Stdout = os.Stdout + mxCmd.Stderr = os.Stderr + docker.PrepareMxCommand(mxCmd) + return mxCmd.Run() + }) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } - - // Clean up duplicate locale files that mx create-project generates. - // MxBuild's AtlasPlugin.LoadTranslations crashes with "An item with the same - // key has already been added" when duplicate translation.json files exist. - if removed := cleanupDuplicateLocaleFiles(absDir); removed > 0 { - fmt.Printf(" Cleaned %d duplicate locale file(s)\n", removed) - } - - // Verify .mpr was created β€” mx create-project names the file after --app-name - mprPath := filepath.Join(absDir, appName+".mpr") - if _, err := os.Stat(mprPath); os.IsNotExist(err) { - // Fallback: check for App.mpr (default when --app-name is not used) - fallback := filepath.Join(absDir, "App.mpr") - if _, err := os.Stat(fallback); err == nil { - mprPath = fallback - } else { - // Last resort: find any .mpr file - matches, _ := filepath.Glob(filepath.Join(absDir, "*.mpr")) - if len(matches) > 0 { - mprPath = matches[0] - } else { - fmt.Fprintf(os.Stderr, "Error: mx create-project did not produce an .mpr file in %s\n", absDir) - os.Exit(1) - } - } + if created.removedLocales > 0 { + fmt.Printf(" Cleaned %d duplicate locale file(s)\n", created.removedLocales) } + mprPath := created.mprPath fmt.Printf(" Created %s\n", mprPath) - - // The project is stamped with the version of the binary that created it, not - // with --version. Resolving the wrong binary therefore yields a project at a - // version the user never asked for, and every later step (init, mxbuild, - // runtime) silently follows it. Check the postcondition rather than trusting - // the resolution: a mismatch here means the model is wrong, so fail loudly - // instead of handing back something that merely looks finished. - if reader, err := mpr.Open(mprPath); err == nil { - created := reader.ProjectVersion().ProductVersion - reader.Close() - if created != "" && created != mendixVersion { - fmt.Fprintf(os.Stderr, - "Error: requested Mendix %s but the created project is %s.\n", - mendixVersion, created) - fmt.Fprintf(os.Stderr, - " mx create-project stamps the project with the version of the binary that ran it (%s).\n", mxPath) - fmt.Fprintf(os.Stderr, - " Run 'mxcli setup mxbuild --version %s' and try again.\n", mendixVersion) - os.Exit(1) - } - fmt.Printf(" Mendix version: %s\n", created) + if created.version != "" { + fmt.Printf(" Mendix version: %s\n", created.version) } // Step 3: Default styling. A blank Atlas app is unmistakably a blank Atlas diff --git a/cmd/mxcli/cmd_new_project.go b/cmd/mxcli/cmd_new_project.go new file mode 100644 index 000000000..04959f3ac --- /dev/null +++ b/cmd/mxcli/cmd_new_project.go @@ -0,0 +1,352 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "archive/zip" + "bytes" + "encoding/binary" + "errors" + "fmt" + "os" + "path" + "path/filepath" + "runtime" + "strings" + "unicode/utf16" + + "github.com/mendixlabs/mxcli/sdk/mpr" +) + +const mxToolsetMaxPathUTF16Units = 259 + +type newProjectOutputState struct { + path string + preexisting bool + info os.FileInfo +} + +type newProjectCreation struct { + mprPath string + version string + removedLocales int +} + +// inspectNewProjectOutput rejects an existing non-empty output before the +// expensive MxBuild lookup and records the identity needed for safe rollback. +func inspectNewProjectOutput(outputDir string) (newProjectOutputState, error) { + state := newProjectOutputState{path: outputDir} + info, err := os.Lstat(outputDir) + if os.IsNotExist(err) { + return state, nil + } + if err != nil { + return state, fmt.Errorf("checking output directory %s: %w", outputDir, err) + } + if info.Mode()&os.ModeSymlink != 0 { + info, err = os.Stat(outputDir) + if err != nil { + return state, fmt.Errorf("resolving output directory %s: %w", outputDir, err) + } + } + if !info.IsDir() { + return state, fmt.Errorf("output path %s exists and is not a directory", outputDir) + } + entries, err := os.ReadDir(outputDir) + if err != nil { + return state, fmt.Errorf("reading output directory %s: %w", outputDir, err) + } + if len(entries) != 0 { + return state, fmt.Errorf("directory %s already exists and is not empty", outputDir) + } + state.preexisting = true + state.info = info + return state, nil +} + +// validateNewProjectOutputPath inspects the blank-project ZIP embedded in the +// resolved MxToolset version. MxToolset rejects a write at 260 UTF-16 code +// units on every OS, so reject the output before extraction can leave debris. +func validateNewProjectOutputPath(outputDir, appName, mendixVersion, mxPath string) error { + longestRelativePath, err := longestBlankProjectTemplatePath(mxPath) + if err != nil { + return fmt.Errorf("inspecting the Mendix %s blank project template: %w", mendixVersion, err) + } + if mprName := appName + ".mpr"; utf16PathLen(mprName) > utf16PathLen(longestRelativePath) { + longestRelativePath = mprName + } + return validateNewProjectPathLength(outputDir, mendixVersion, longestRelativePath) +} + +func validateNewProjectPathLength(outputDir, mendixVersion, longestRelativePath string) error { + projectedPath := filepath.Join(mxToolsetOutputPath(outputDir), filepath.FromSlash(longestRelativePath)) + projectedLength := utf16PathLen(projectedPath) + if projectedLength <= mxToolsetMaxPathUTF16Units { + return nil + } + + maxOutputLength := mxToolsetMaxPathUTF16Units - utf16PathLen(filepath.FromSlash(longestRelativePath)) - 1 + return fmt.Errorf( + "output directory is too long for Mendix %s project creation: %s\n"+ + " The longest generated path would be %d UTF-16 code units; MxToolset supports at most %d.\n"+ + " Use a shorter --output-dir (at most %d UTF-16 code units for this version)", + mendixVersion, outputDir, projectedLength, mxToolsetMaxPathUTF16Units, maxOutputLength, + ) +} + +// Windows retains a junction/subst spelling as the current directory, which is +// a useful explicit short-path workaround. Unix getcwd resolves symlinks, so +// account for the physical prefix there to avoid underestimating MxToolset's +// path string when the output itself does not exist yet. +func mxToolsetOutputPath(outputDir string) string { + if runtime.GOOS == "windows" { + return outputDir + } + + current := outputDir + var missing []string + for { + resolved, err := filepath.EvalSymlinks(current) + if err == nil { + for i := len(missing) - 1; i >= 0; i-- { + resolved = filepath.Join(resolved, missing[i]) + } + return resolved + } + if !os.IsNotExist(err) { + return outputDir + } + parent := filepath.Dir(current) + if parent == current { + return outputDir + } + missing = append(missing, filepath.Base(current)) + current = parent + } +} + +func utf16PathLen(value string) int { + return len(utf16.Encode([]rune(value))) +} + +// longestBlankProjectTemplatePath locates the largest embedded ZIP with a root +// .mpr in Mendix.Modeler.Core.dll. This selects the NewProject resource over the +// much smaller SystemProject resource without depending on .resources internals. +func longestBlankProjectTemplatePath(mxPath string) (string, error) { + if resolved, err := filepath.EvalSymlinks(mxPath); err == nil { + mxPath = resolved + } + corePath := filepath.Join(filepath.Dir(mxPath), "Mendix.Modeler.Core.dll") + data, err := os.ReadFile(corePath) + if err != nil { + return "", fmt.Errorf("reading %s: %w", corePath, err) + } + + var bestPath string + bestEntryCount := -1 + for searchEnd := len(data); searchEnd >= 4; { + eocd := bytes.LastIndex(data[:searchEnd], []byte{'P', 'K', 5, 6}) + if eocd < 0 { + break + } + searchEnd = eocd + if eocd+22 > len(data) { + continue + } + + commentLength := int(binary.LittleEndian.Uint16(data[eocd+20 : eocd+22])) + archiveEnd := eocd + 22 + commentLength + centralSize := int(binary.LittleEndian.Uint32(data[eocd+12 : eocd+16])) + centralOffset := int(binary.LittleEndian.Uint32(data[eocd+16 : eocd+20])) + archiveStart := eocd - centralSize - centralOffset + if archiveStart < 0 || archiveEnd > len(data) || archiveStart >= archiveEnd { + continue + } + + archiveData := data[archiveStart:archiveEnd] + reader, err := zip.NewReader(bytes.NewReader(archiveData), int64(len(archiveData))) + if err != nil { + continue + } + + hasRootMPR := false + longest := "" + for _, file := range reader.File { + name := strings.TrimPrefix(file.Name, "./") + if !strings.Contains(name, "/") && strings.EqualFold(path.Ext(name), ".mpr") { + hasRootMPR = true + } + if !file.FileInfo().IsDir() && utf16PathLen(name) > utf16PathLen(longest) { + longest = name + } + } + if hasRootMPR && longest != "" && len(reader.File) > bestEntryCount { + bestPath = longest + bestEntryCount = len(reader.File) + } + } + + if bestPath == "" { + return "", fmt.Errorf("blank project archive not found in %s", corePath) + } + return bestPath, nil +} + +// createMendixProjectWithRollback runs MxToolset only after the path preflight. +// Any extraction or validation failure removes output created by this command; +// a pre-existing empty output directory itself is preserved. +func createMendixProjectWithRollback( + outputDir, appName, expectedVersion, mxPath string, + create func(projectDir string) error, +) (result newProjectCreation, resultErr error) { + state, err := inspectNewProjectOutput(outputDir) + if err != nil { + return result, err + } + + createdDirs, err := ensureNewProjectOutputDirectory(outputDir) + if err != nil { + return result, err + } + if !state.preexisting { + state.info, err = os.Stat(outputDir) + if err != nil { + for i := len(createdDirs) - 1; i >= 0; i-- { + _ = os.Remove(createdDirs[i]) + } + return result, err + } + } + + defer func() { + if resultErr == nil { + return + } + cleanupErr := rollbackNewProjectOutput(state, createdDirs) + if cleanupErr != nil { + resultErr = errors.Join(resultErr, cleanupErr) + } + }() + + if err := create(outputDir); err != nil { + return result, fmt.Errorf("creating project: %w", err) + } + + result.removedLocales = cleanupDuplicateLocaleFiles(outputDir) + mprPath, err := findCreatedMPR(outputDir, appName) + if err != nil { + return result, err + } + + if reader, err := mpr.Open(mprPath); err == nil { + result.version = reader.ProjectVersion().ProductVersion + reader.Close() + if result.version != "" && result.version != expectedVersion { + return result, fmt.Errorf( + "requested Mendix %s but the created project is %s\n"+ + " mx create-project stamps the project with the version of the binary that ran it (%s)\n"+ + " Run 'mxcli setup mxbuild --version %s' and try again", + expectedVersion, result.version, mxPath, expectedVersion, + ) + } + } + + result.mprPath = mprPath + return result, nil +} + +func findCreatedMPR(projectDir, appName string) (string, error) { + preferred := filepath.Join(projectDir, appName+".mpr") + if _, err := os.Stat(preferred); err == nil { + return preferred, nil + } + fallback := filepath.Join(projectDir, "App.mpr") + if _, err := os.Stat(fallback); err == nil { + return fallback, nil + } + matches, _ := filepath.Glob(filepath.Join(projectDir, "*.mpr")) + if len(matches) != 0 { + return matches[0], nil + } + return "", fmt.Errorf("mx create-project did not produce an .mpr file in %s", projectDir) +} + +func ensureNewProjectOutputDirectory(outputDir string) ([]string, error) { + var missing []string + for current := outputDir; ; current = filepath.Dir(current) { + info, err := os.Stat(current) + if err == nil { + if !info.IsDir() { + return nil, fmt.Errorf("output parent %s is not a directory", current) + } + break + } + if !os.IsNotExist(err) { + return nil, err + } + missing = append(missing, current) + next := filepath.Dir(current) + if next == current { + return nil, fmt.Errorf("no existing parent for output directory %s", outputDir) + } + } + + created := make([]string, 0, len(missing)) + for i := len(missing) - 1; i >= 0; i-- { + if err := os.Mkdir(missing[i], 0o755); err != nil { + for j := len(created) - 1; j >= 0; j-- { + _ = os.Remove(created[j]) + } + return nil, err + } + created = append(created, missing[i]) + } + return created, nil +} + +func rollbackNewProjectOutput(state newProjectOutputState, createdDirs []string) error { + info, err := os.Stat(state.path) + if err != nil { + if os.IsNotExist(err) && !state.preexisting { + removeNewProjectParentDirs(createdDirs, true) + return nil + } + return fmt.Errorf("rolling back project output %s: %w", state.path, err) + } + if !os.SameFile(state.info, info) { + return fmt.Errorf("refusing to roll back project output %s because the directory was replaced", state.path) + } + + if state.preexisting { + entries, err := os.ReadDir(state.path) + if err != nil { + return fmt.Errorf("reading project output during rollback: %w", err) + } + var cleanupErrors []error + for _, entry := range entries { + if err := os.RemoveAll(filepath.Join(state.path, entry.Name())); err != nil { + cleanupErrors = append(cleanupErrors, err) + } + } + if err := errors.Join(cleanupErrors...); err != nil { + return fmt.Errorf("cleaning failed project from %s: %w", state.path, err) + } + return nil + } + + if err := os.RemoveAll(state.path); err != nil { + return fmt.Errorf("removing failed project output %s: %w", state.path, err) + } + removeNewProjectParentDirs(createdDirs, true) + return nil +} + +func removeNewProjectParentDirs(createdDirs []string, outputRemoved bool) { + start := len(createdDirs) - 1 + if outputRemoved { + start-- + } + for i := start; i >= 0; i-- { + _ = os.Remove(createdDirs[i]) + } +} diff --git a/cmd/mxcli/cmd_new_test.go b/cmd/mxcli/cmd_new_test.go index bc75a0eed..39feca0d4 100644 --- a/cmd/mxcli/cmd_new_test.go +++ b/cmd/mxcli/cmd_new_test.go @@ -3,10 +3,14 @@ package main import ( + "archive/zip" + "bytes" + "errors" "net/http" "net/http/httptest" "os" "path/filepath" + "strings" "testing" ) @@ -51,3 +55,319 @@ func TestDownloadMxcliBinary_SuccessWritesBinary(t *testing.T) { t.Errorf("file content mismatch: got %q, want %q", got, content) } } + +func TestValidateNewProjectPathLength_Boundary(t *testing.T) { + longestRelativePath := asciiRelativePath(t, 181) + for _, test := range []struct { + name string + outputLength int + wantError bool + projectedPath int + }{ + {name: "259 succeeds", outputLength: 77, projectedPath: 259}, + {name: "260 fails", outputLength: 78, wantError: true, projectedPath: 260}, + } { + t.Run(test.name, func(t *testing.T) { + outputDir := absolutePathWithLength(t, test.outputLength) + err := validateNewProjectPathLength(outputDir, "11.12.2", longestRelativePath) + if (err != nil) != test.wantError { + t.Fatalf("validate path: error=%v wantError=%v", err, test.wantError) + } + projected := filepath.Join(outputDir, filepath.FromSlash(longestRelativePath)) + if got := utf16PathLen(projected); got != test.projectedPath { + t.Fatalf("projected path length = %d, want %d", got, test.projectedPath) + } + if test.wantError { + message := err.Error() + for _, expected := range []string{"260 UTF-16", "at most 259", "shorter --output-dir", "at most 77"} { + if !strings.Contains(message, expected) { + t.Errorf("error %q does not contain %q", message, expected) + } + } + } + }) + } +} + +func TestValidateNewProjectPathLength_CountsSurrogatePairs(t *testing.T) { + if got := utf16PathLen("AπŸ˜€ν•œ"); got != 4 { + t.Fatalf("UTF-16 length = %d, want 4", got) + } + longestRelativePath := filepath.ToSlash(filepath.Join("ν•œκΈ€", "deep-πŸ˜€.txt")) + base := filepath.Join(volumeRoot(t), "ν”„λ‘œμ νŠΈ-πŸ˜€") + remaining := 260 - utf16PathLen(base) - utf16PathLen(filepath.FromSlash(longestRelativePath)) - 2 + outputDir := filepath.Join(base, strings.Repeat("x", remaining)) + if got := utf16PathLen(filepath.Join(outputDir, filepath.FromSlash(longestRelativePath))); got != 260 { + t.Fatalf("projected Unicode path length = %d, want 260", got) + } + if err := validateNewProjectPathLength(outputDir, "11.12.2", longestRelativePath); err == nil { + t.Fatal("expected a 260-unit Unicode path to be rejected") + } +} + +func TestLongestBlankProjectTemplatePath_FindsVersionSpecificArchive(t *testing.T) { + mxPath := fakeMxWithCore(t, + embeddedZip(t, map[string]string{ + "System.mpr": "system", + "short.txt": "short", + }), + embeddedZip(t, map[string]string{ + "StarterApp_Blank.mpr": "project", + "a.txt": "a", + "nested/b.txt": "b", + "nested/longest-name.txt": "longest", + }), + ) + + got, err := longestBlankProjectTemplatePath(mxPath) + if err != nil { + t.Fatalf("inspect embedded project: %v", err) + } + if got != "nested/longest-name.txt" { + t.Fatalf("longest path = %q, want version-specific NewProject archive path", got) + } +} + +func TestLongestBlankProjectTemplatePath_InstalledMx(t *testing.T) { + mxPath := os.Getenv("MXCLI_TEST_MX") + if mxPath == "" { + t.Skip("set MXCLI_TEST_MX to an installed mx binary") + } + got, err := longestBlankProjectTemplatePath(mxPath) + if err != nil { + t.Fatalf("inspect installed MxToolset: %v", err) + } + if got == "" { + t.Fatal("installed blank project template had no files") + } + t.Logf("longest blank-project template path: %d UTF-16 units: %s", utf16PathLen(got), got) +} + +func TestCreateMendixProjectWithRollback_FailureLeavesNoOutput(t *testing.T) { + parent := filepath.Join(t.TempDir(), "new", "nested") + target := filepath.Join(parent, "project") + forced := errors.New("forced extraction failure") + + _, err := createMendixProjectWithRollback(target, "Failure", "test", "fake-mx", + func(projectDir string) error { + if err := os.WriteFile(filepath.Join(projectDir, "partial.txt"), []byte("partial"), 0o644); err != nil { + return err + } + return forced + }) + if !errors.Is(err, forced) { + t.Fatalf("error = %v, want forced extraction failure", err) + } + if _, err := os.Lstat(target); !os.IsNotExist(err) { + t.Fatalf("failed creation left output behind: %v", err) + } + if _, err := os.Lstat(filepath.Join(filepath.Dir(parent), "nested")); !os.IsNotExist(err) { + t.Fatalf("failed creation left newly-created parent directories behind: %v", err) + } +} + +func TestCreateMendixProjectWithRollback_FailurePreservesExistingEmptyOutput(t *testing.T) { + target := filepath.Join(t.TempDir(), "project") + if err := os.Mkdir(target, 0o750); err != nil { + t.Fatal(err) + } + before, err := os.Stat(target) + if err != nil { + t.Fatal(err) + } + forced := errors.New("forced extraction failure") + + _, err = createMendixProjectWithRollback(target, "Failure", "test", "fake-mx", + func(projectDir string) error { + if err := os.WriteFile(filepath.Join(projectDir, "partial.txt"), []byte("partial"), 0o644); err != nil { + return err + } + return forced + }) + if !errors.Is(err, forced) { + t.Fatalf("error = %v, want forced extraction failure", err) + } + after, err := os.Stat(target) + if err != nil { + t.Fatalf("existing empty output was removed: %v", err) + } + if !os.SameFile(before, after) { + t.Fatal("existing empty output was replaced after failed creation") + } + entries, err := os.ReadDir(target) + if err != nil || len(entries) != 0 { + t.Fatalf("existing output is no longer empty: entries=%v err=%v", entries, err) + } +} + +func TestCreateMendixProjectWithRollback_FailurePreservesSymlinkOutput(t *testing.T) { + root := t.TempDir() + realOutput := filepath.Join(root, "real-output") + if err := os.Mkdir(realOutput, 0o755); err != nil { + t.Fatal(err) + } + outputLink := filepath.Join(root, "short-output") + if err := os.Symlink(realOutput, outputLink); err != nil { + t.Skipf("symbolic links are unavailable: %v", err) + } + forced := errors.New("forced extraction failure") + + _, err := createMendixProjectWithRollback(outputLink, "Failure", "test", "fake-mx", + func(projectDir string) error { + if err := os.WriteFile(filepath.Join(projectDir, "partial.txt"), []byte("partial"), 0o644); err != nil { + return err + } + return forced + }) + if !errors.Is(err, forced) { + t.Fatalf("error = %v, want forced extraction failure", err) + } + if info, err := os.Lstat(outputLink); err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("output symlink was not preserved: info=%v err=%v", info, err) + } + entries, err := os.ReadDir(realOutput) + if err != nil || len(entries) != 0 { + t.Fatalf("symlink target is no longer empty: entries=%v err=%v", entries, err) + } +} + +func TestCreateMendixProjectWithRollback_SucceedsWithExistingEmptyOutput(t *testing.T) { + target := filepath.Join(t.TempDir(), "project") + if err := os.Mkdir(target, 0o750); err != nil { + t.Fatal(err) + } + + created, err := createMendixProjectWithRollback(target, "Existing", "test", "fake-mx", + func(projectDir string) error { + if err := os.WriteFile(filepath.Join(projectDir, "Existing.mpr"), []byte("fake"), 0o644); err != nil { + return err + } + return os.WriteFile(filepath.Join(projectDir, "complete.txt"), []byte("complete"), 0o644) + }) + if err != nil { + t.Fatalf("create into existing empty output: %v", err) + } + if created.mprPath != filepath.Join(target, "Existing.mpr") { + t.Fatalf("mpr path = %q, want final output path", created.mprPath) + } + if data, err := os.ReadFile(filepath.Join(target, "complete.txt")); err != nil || string(data) != "complete" { + t.Fatalf("completed project was not committed: data=%q err=%v", data, err) + } +} + +func TestCreateMendixProjectWithRollback_MissingMPRRollsBack(t *testing.T) { + target := filepath.Join(t.TempDir(), "project") + _, err := createMendixProjectWithRollback(target, "Missing", "test", "fake-mx", + func(projectDir string) error { + return os.WriteFile(filepath.Join(projectDir, "partial.txt"), []byte("partial"), 0o644) + }) + if err == nil || !strings.Contains(err.Error(), "did not produce an .mpr") { + t.Fatalf("error = %v, want missing .mpr error", err) + } + if _, err := os.Lstat(target); !os.IsNotExist(err) { + t.Fatalf("missing .mpr left output behind: %v", err) + } +} + +func TestCreateMendixProjectWithRollback_RejectsNonEmptyOutputBeforeCreation(t *testing.T) { + target := filepath.Join(t.TempDir(), "project") + if err := os.Mkdir(target, 0o755); err != nil { + t.Fatal(err) + } + marker := filepath.Join(target, "keep.txt") + if err := os.WriteFile(marker, []byte("keep"), 0o644); err != nil { + t.Fatal(err) + } + called := false + + _, err := createMendixProjectWithRollback(target, "Existing", "test", "fake-mx", + func(string) error { + called = true + return nil + }) + if err == nil || !strings.Contains(err.Error(), "not empty") { + t.Fatalf("error = %v, want non-empty output error", err) + } + if called { + t.Fatal("creator ran for a non-empty output directory") + } + if data, err := os.ReadFile(marker); err != nil || string(data) != "keep" { + t.Fatalf("existing file changed: data=%q err=%v", data, err) + } +} + +func fakeMxWithCore(t *testing.T, archives ...[]byte) string { + t.Helper() + dir := t.TempDir() + mxPath := filepath.Join(dir, "mx") + if err := os.WriteFile(mxPath, nil, 0o755); err != nil { + t.Fatal(err) + } + data := []byte("managed assembly prefix") + for _, archive := range archives { + data = append(data, archive...) + } + data = append(data, []byte("managed assembly suffix")...) + if err := os.WriteFile(filepath.Join(dir, "Mendix.Modeler.Core.dll"), data, 0o644); err != nil { + t.Fatal(err) + } + return mxPath +} + +func embeddedZip(t *testing.T, files map[string]string) []byte { + t.Helper() + var buffer bytes.Buffer + writer := zip.NewWriter(&buffer) + for name, content := range files { + file, err := writer.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := file.Write([]byte(content)); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return buffer.Bytes() +} + +func absolutePathWithLength(t *testing.T, length int) string { + t.Helper() + root := volumeRoot(t) + relativeLength := length - utf16PathLen(root) + result := filepath.Join(root, asciiRelativePath(t, relativeLength)) + if got := utf16PathLen(result); got != length { + t.Fatalf("constructed path length = %d, want %d (%s)", got, length, result) + } + return result +} + +func volumeRoot(t *testing.T) string { + t.Helper() + volume := filepath.VolumeName(t.TempDir()) + return volume + string(filepath.Separator) +} + +func asciiRelativePath(t *testing.T, length int) string { + t.Helper() + if length < 1 { + t.Fatalf("relative path length must be positive, got %d", length) + } + const componentLength = 80 + var components []string + for length > componentLength { + partLength := componentLength + if length == componentLength+1 { + partLength-- // leave one unit after the separator + } + components = append(components, strings.Repeat("d", partLength)) + length -= partLength + 1 // include the path separator + } + if length < 1 { + t.Fatalf("cannot construct relative path with requested remainder %d", length) + } + components = append(components, strings.Repeat("f", length)) + return filepath.Join(components...) +}