Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"area": "mdl/executor", "date": "2026-10-09", "symptom": "marketplace update --save-edits + exec corrupts constant folder paths containing a literal '/' in the folder name: EncryptionKey in `Private - String en/de-cryption` > `Apis` was replayed into `Private - String en` > `de-cryption` > `Apis`; exec exited 0.", "cause": "ContainerHierarchy.BuildFolderPath joined folder names with a bare '/', and every path walker (resolveFolder, findFolderByPath, lookupFolder, the page builder's) split on it. Studio Pro does not reserve '/' in a folder name, so the path was ambiguous. Fix: mdl/folderpath escapes '/' as `\\/` and '\\' as `\\\\` per segment and splits leniently (a backslash before anything else stays literal); the describers quote the clause with mdlQuote so the escape survives both string rules.", "file": "mdl/folderpath/folderpath.go; mdl/executor/hierarchy.go (BuildFolderPath); helpers.go, cmd_folders.go, cmd_microflows_build.go, cmd_pages_builder.go (walkers)", "insight": "The reported doctype (constant) was not the blast radius: BuildFolderPath feeds every describer and four separate split-on-'/' walkers. Fix the join and split once and grep `Split(.*folder.*\"/\")` for the walkers. A serialised path needs an escape that is not a doubled separator (`a///b` is ambiguous) and that reads the same under mdl 0 and mdl 1 string rules: `\\/` does, because mdl 0 keeps a backslash before an unknown character. Several describers also wrote `folder '%s'` without doubling apostrophes - the same round-trip gap, now mdlQuote.", "refs": ["mendixlabs/mxcli#1367"], "test": "mdl/executor/folder_path_slash_test.go TestDescribeExecRoundTrip_FolderNameContainingSlash; mdl/folderpath/folderpath_test.go"}
24 changes: 5 additions & 19 deletions cmd/mxcli/project_tree.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

modelsdkbackend "github.com/mendixlabs/mxcli/mdl/backend/modelsdk"
"github.com/mendixlabs/mxcli/mdl/executor"
"github.com/mendixlabs/mxcli/mdl/folderpath"
"github.com/mendixlabs/mxcli/mdl/types"
"github.com/mendixlabs/mxcli/model"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -820,7 +821,7 @@ func getOrCreateFolder(root *TreeNode, cache map[string]*TreeNode, path string)
if builtPath != "" {
builtPath += "/"
}
builtPath += part
builtPath += folderpath.Escape(part)

if node, ok := cache[builtPath]; ok {
current = node
Expand Down Expand Up @@ -1057,23 +1058,8 @@ func buildMenuTreeNodes(parent *TreeNode, items []*types.NavMenuItem) {
}
}

// splitFolderPath splits a folder path like "Parent/Child" into parts.
// splitFolderPath splits a folder path like "Parent/Child" into folder names,
// reading a `\/` inside a name as part of it (mendixlabs/mxcli#1367).
func splitFolderPath(path string) []string {
if path == "" {
return nil
}
var parts []string
start := 0
for i := 0; i < len(path); i++ {
if path[i] == '/' {
if i > start {
parts = append(parts, path[start:i])
}
start = i + 1
}
}
if start < len(path) {
parts = append(parts, path[start:])
}
return parts
return folderpath.Split(path)
}
16 changes: 16 additions & 0 deletions docs-site/src/reference/organization/create-folder.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,22 @@ CREATE PAGE MyModule.Order_Edit FOLDER 'Orders'
};
```

### A folder whose name contains `/`

Studio Pro allows `/` inside a folder name, so in a `FOLDER '…'` path a slash
that belongs to the name is written `\/` (and a backslash `\\`). This is one
folder, `Private - String en/de-cryption`, holding `Apis`:

```sql
CREATE CONSTANT Encryption.EncryptionKey FOLDER 'Private - String en\/de-cryption/Apis' (
Type: String,
DefaultValue: ''
);
```

`DESCRIBE` writes the escape for you, so its output files the document back
where it was. A backslash before any other character is an ordinary character.

## See Also

[CREATE MODULE](create-module.md), [DROP FOLDER](drop-folder.md), [MOVE](move.md)
38 changes: 38 additions & 0 deletions mdl-examples/bug-tests/1367-folder-name-containing-slash.mdl
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
mdl 1;
-- ============================================================================
-- A folder name containing '/' split into nested folders (mendixlabs/mxcli#1367)
-- ============================================================================
--
-- Symptom (before fix): "marketplace update --save-edits + exec corrupts
-- constant folder paths containing a literal '/' in the folder name".
-- The Encryption module keeps EncryptionKey in the Studio Pro folder
-- `Private - String en/de-cryption` > `Apis`. DESCRIBE (which --save-edits
-- writes) joined the folder names with a bare '/':
--
-- create or modify constant Encryption.EncryptionKey folder 'Private - String en/de-cryption/Apis' (
--
-- and replaying it filed the constant in `Private - String en` >
-- `de-cryption` > `Apis`. exec exited 0.
--
-- Fix: inside a folder-path segment a '/' is written `\/` (a backslash `\\`);
-- the path walkers read it back as part of the name (mdl/folderpath).
--
-- Usage:
-- mxcli exec mdl-examples/bug-tests/1367-folder-name-containing-slash.mdl -p app.mpr
-- mxcli -p app.mpr -c "list folders in BugTest1367"
-- Expect two folders: `Private - String en\/de-cryption` and
-- `Private - String en\/de-cryption/Apis` (holding EncryptionKey), not three.
-- ============================================================================

create module BugTest1367;

create or modify constant BugTest1367.EncryptionKey folder 'Private - String en\/de-cryption/Apis' (
Type: String,
DefaultValue: ''
);

-- Re-running the statement must find the same folder, not create a sibling.
create or modify constant BugTest1367.EncryptionKey folder 'Private - String en\/de-cryption/Apis' (
Type: String,
DefaultValue: ''
);
7 changes: 1 addition & 6 deletions mdl/executor/cmd_constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,7 @@ func outputConstantMDL(ctx *ExecContext, c *model.Constant, moduleName string) e
}
}
// The folder is a clause right after the name (R9).
folder := ""
if h, _ := getHierarchy(ctx); h != nil {
if folderPath := h.BuildFolderPath(c.ContainerID); folderPath != "" {
folder = fmt.Sprintf(" folder '%s'", strings.ReplaceAll(folderPath, "'", "''"))
}
}
folder := describeFolderClause(ctx, c.ContainerID)
// The properties are a ( Key: value ) list with Studio Pro's names
// (phase 3.6, ako/mxcli#755); ExposedToClient is omitted at its default.
fmt.Fprintf(ctx.Output, "create or modify constant %s.%s%s (\n", moduleName, c.Name, folder)
Expand Down
2 changes: 1 addition & 1 deletion mdl/executor/cmd_export_mappings.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ func describeExportMapping(ctx *ExecContext, name ast.QualifiedName) error {
// Without this the description round-trips to the module root: replaying
// it in a fresh project would recreate the mapping unfiled (#932).
if folderPath := h.BuildFolderPath(em.ContainerID); folderPath != "" {
fmt.Fprintf(ctx.Output, " folder '%s'\n", folderPath)
fmt.Fprintf(ctx.Output, " folder %s\n", mdlQuote(ctx, folderPath))
}

if em.JsonStructure != "" {
Expand Down
6 changes: 2 additions & 4 deletions mdl/executor/cmd_folders.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,18 @@ import (

"github.com/mendixlabs/mxcli/mdl/ast"
mdlerrors "github.com/mendixlabs/mxcli/mdl/errors"
"github.com/mendixlabs/mxcli/mdl/folderpath"
"github.com/mendixlabs/mxcli/mdl/types"
"github.com/mendixlabs/mxcli/model"
)

// findFolderByPath walks a folder path under a module and returns the folder ID.
func findFolderByPath(ctx *ExecContext, moduleID model.ID, folderPath string, folders []*types.FolderInfo) (model.ID, error) {
parts := strings.Split(folderPath, "/")
parts := folderpath.Split(folderPath)
currentContainerID := moduleID

var targetFolderID model.ID
for i, part := range parts {
if part == "" {
continue
}

var found bool
for _, f := range folders {
Expand Down
2 changes: 1 addition & 1 deletion mdl/executor/cmd_import_mappings.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ func describeImportMapping(ctx *ExecContext, name ast.QualifiedName) error {
// Without this the description round-trips to the module root: replaying
// it in a fresh project would recreate the mapping unfiled (#932).
if folderPath := h.BuildFolderPath(im.ContainerID); folderPath != "" {
fmt.Fprintf(ctx.Output, " folder '%s'\n", folderPath)
fmt.Fprintf(ctx.Output, " folder %s\n", mdlQuote(ctx, folderPath))
}

if im.JsonStructure != "" {
Expand Down
2 changes: 1 addition & 1 deletion mdl/executor/cmd_jsonstructures.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ func describeJsonStructure(ctx *ExecContext, name ast.QualifiedName) error {
// Re-executable CREATE OR MODIFY statement
fmt.Fprintf(ctx.Output, "create or modify json structure %s", qualifiedName)
if folderPath := h.BuildFolderPath(js.ContainerID); folderPath != "" {
fmt.Fprintf(ctx.Output, "\n folder '%s'", folderPath)
fmt.Fprintf(ctx.Output, "\n folder %s", mdlQuote(ctx, folderPath))
}
if docClause {
fmt.Fprintf(ctx.Output, "\n comment '%s'", strings.ReplaceAll(js.Documentation, "'", "''"))
Expand Down
2 changes: 1 addition & 1 deletion mdl/executor/cmd_messagedefinition_documents.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ func describeMessageDefinitionDocument(ctx *ExecContext, d *model.MessageDefinit
fmt.Fprintf(ctx.Output, "create or modify message definition %s\n", messageDocumentQN(ctx, d))
if h, err := getHierarchy(ctx); err == nil {
if folder := h.BuildFolderPath(d.ContainerID); folder != "" {
fmt.Fprintf(ctx.Output, " folder '%s'\n", folder)
fmt.Fprintf(ctx.Output, " folder %s\n", mdlQuote(ctx, folder))
}
}
if d.Root == nil {
Expand Down
2 changes: 1 addition & 1 deletion mdl/executor/cmd_messagedefinitions.go
Original file line number Diff line number Diff line change
Expand Up @@ -591,7 +591,7 @@ func execDescribeMessageDefinitionCollection(ctx *ExecContext, name ast.Qualifie
fmt.Fprintf(ctx.Output, "create or modify message definition collection %s\n", name.String())
if h, err := getHierarchy(ctx); err == nil {
if folder := h.BuildFolderPath(c.ContainerID); folder != "" {
fmt.Fprintf(ctx.Output, " folder '%s'\n", folder)
fmt.Fprintf(ctx.Output, " folder %s\n", mdlQuote(ctx, folder))
}
}
fmt.Fprintln(ctx.Output, "{")
Expand Down
6 changes: 2 additions & 4 deletions mdl/executor/cmd_microflows_build.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

"github.com/mendixlabs/mxcli/mdl/ast"
mdlerrors "github.com/mendixlabs/mxcli/mdl/errors"
"github.com/mendixlabs/mxcli/mdl/folderpath"
"github.com/mendixlabs/mxcli/mdl/types"
"github.com/mendixlabs/mxcli/model"
"github.com/mendixlabs/mxcli/sdk/microflows"
Expand Down Expand Up @@ -778,10 +779,7 @@ func lookupFolder(ctx *ExecContext, moduleID model.ID, folderPath string) (model
return "", false
}
current := moduleID
for _, part := range strings.Split(folderPath, "/") {
if part == "" {
continue
}
for _, part := range folderpath.Split(folderPath) {
found := false
for _, f := range folders {
if f.ContainerID == current && f.Name == part {
Expand Down
2 changes: 1 addition & 1 deletion mdl/executor/cmd_odata.go
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ func outputPublishedODataServiceMDL(ctx *ExecContext, svc *model.PublishedODataS
// The folder is a clause after the name (R9); `Folder:` is its alias.
folder := ""
if folderPath != "" {
folder = " folder " + mdlQuoted(folderPath)
folder = " folder " + mdlQuote(ctx, folderPath)
}
fmt.Fprintf(ctx.Output, "create or modify published odata service %s.%s%s (\n", moduleName, svc.Name, folder)

Expand Down
8 changes: 2 additions & 6 deletions mdl/executor/cmd_pages_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/mendixlabs/mxcli/mdl/ast"
"github.com/mendixlabs/mxcli/mdl/backend"
mdlerrors "github.com/mendixlabs/mxcli/mdl/errors"
"github.com/mendixlabs/mxcli/mdl/folderpath"
"github.com/mendixlabs/mxcli/mdl/types"
"github.com/mendixlabs/mxcli/model"
"github.com/mendixlabs/mxcli/sdk/domainmodel"
Expand Down Expand Up @@ -350,14 +351,9 @@ func (pb *pageBuilder) resolveFolder(folderPath string) (model.ID, error) {
return "", mdlerrors.NewBackend("list folders", err)
}

// Split path into parts
parts := strings.Split(folderPath, "/")
currentContainerID := pb.moduleID

for _, part := range parts {
if part == "" {
continue
}
for _, part := range folderpath.Split(folderPath) {

// Find folder with this name under current container
var foundFolder *types.FolderInfo
Expand Down
2 changes: 1 addition & 1 deletion mdl/executor/cmd_published_rest.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ func describePublishedRestService(ctx *ExecContext, name ast.QualifiedName) erro
// The folder is a clause after the name (R9); `Folder:` is its alias.
folder := ""
if folderPath := h.BuildFolderPath(svc.ContainerID); folderPath != "" {
folder = " folder " + mdlQuoted(folderPath)
folder = " folder " + mdlQuote(ctx, folderPath)
}
fmt.Fprintf(ctx.Output, "create or modify published rest service %s%s (\n", qualifiedName, folder)
fmt.Fprintf(ctx.Output, " Path: %s", mdlQuoted(svc.Path))
Expand Down
2 changes: 1 addition & 1 deletion mdl/executor/cmd_rest_clients.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ func outputConsumedRestServiceMDL(ctx *ExecContext, svc *model.ConsumedRestServi
folder := ""
if h, err := getHierarchy(ctx); err == nil && h != nil {
if folderPath := h.BuildFolderPath(svc.ContainerID); folderPath != "" {
folder = " folder " + mdlQuoted(folderPath)
folder = " folder " + mdlQuote(ctx, folderPath)
}
}
fmt.Fprintf(w, "create or modify consumed rest service %s.%s%s (\n", moduleName, svc.Name, folder)
Expand Down
4 changes: 1 addition & 3 deletions mdl/executor/document_placement.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@
package executor

import (
"strings"

mdlerrors "github.com/mendixlabs/mxcli/mdl/errors"
"github.com/mendixlabs/mxcli/model"
)
Expand Down Expand Up @@ -62,7 +60,7 @@ func describeFolderClause(ctx *ExecContext, containerID model.ID) string {
if path == "" {
return ""
}
return " folder '" + strings.ReplaceAll(path, "'", "''") + "'"
return " folder " + mdlQuote(ctx, path)
}

// containerForDocument picks the container a CREATE OR MODIFY should use, in
Expand Down
83 changes: 83 additions & 0 deletions mdl/executor/folder_path_slash_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// SPDX-License-Identifier: Apache-2.0

package executor

import (
"reflect"
"testing"

"github.com/mendixlabs/mxcli/mdl/ast"
"github.com/mendixlabs/mxcli/mdl/backend/mock"
"github.com/mendixlabs/mxcli/mdl/types"
"github.com/mendixlabs/mxcli/mdl/visitor"
"github.com/mendixlabs/mxcli/model"
)

// mendixlabs/mxcli#1367: "marketplace update --save-edits + exec corrupts
// constant folder paths containing a literal '/' in the folder name". The
// Encryption module keeps EncryptionKey in `Private - String en/de-cryption`
// › `Apis`; the saved edit replayed it into `Private - String en` ›
// `de-cryption` › `Apis`, and exec exited 0.
//
// --save-edits writes DESCRIBE output, so the test is the round trip itself:
// describe the constant, parse what was written, resolve its folder in a
// project that does not have it yet, and compare the folders created with the
// ones described.
func TestDescribeExecRoundTrip_FolderNameContainingSlash(t *testing.T) {
mod := mkModule("Encryption")
outer := nextID("fld")
inner := nextID("fld")
c := mkConstant(inner, "EncryptionKey", "String", "")

h := mkHierarchy(mod)
withContainer(h, outer, mod.ID)
withContainer(h, inner, outer)
h.folderNames[outer] = "Private - String en/de-cryption"
h.folderNames[inner] = "Apis"

mb := &mock.MockBackend{
IsConnectedFunc: func() bool { return true },
ListConstantsFunc: func() ([]*model.Constant, error) { return []*model.Constant{c}, nil },
}
ctx, buf := newMockCtx(t, withBackend(mb), withHierarchy(h))
assertNoError(t, describeConstant(ctx, ast.QualifiedName{Module: "Encryption", Name: "EncryptionKey"}))

prog, errs := visitor.Build(buf.String())
if len(errs) > 0 {
t.Fatalf("describe output does not parse: %v\n%s", errs, buf.String())
}
var folder string
for _, s := range prog.Statements {
if cs, ok := s.(*ast.CreateConstantStmt); ok {
folder = cs.Folder
}
}
if folder == "" {
t.Fatalf("no folder clause in the describe output:\n%s", buf.String())
}

var probe placementProbe
fresh, _ := folderMockBackend(t, mod, &probe)
var created []*types.FolderInfo
fresh.ListFoldersFunc = func() ([]*types.FolderInfo, error) { return created, nil }
fresh.CreateFolderFunc = func(f *model.Folder) error {
created = append(created, &types.FolderInfo{ID: f.ID, ContainerID: f.ContainerID, Name: f.Name})
return nil
}
ctx2, _ := newMockCtx(t, withBackend(fresh), withHierarchy(mkHierarchy(mod)))
if _, err := resolveFolder(ctx2, mod.ID, folder); err != nil {
t.Fatal(err)
}

var names []string
for _, f := range created {
names = append(names, f.Name)
}
want := []string{"Private - String en/de-cryption", "Apis"}
if !reflect.DeepEqual(names, want) {
t.Errorf("folder clause %q recreated folders %q, want %q", folder, names, want)
}
if len(created) == 2 && (created[0].ContainerID != mod.ID || created[1].ContainerID != created[0].ID) {
t.Errorf("folders not nested as described: %+v", created)
}
}
8 changes: 2 additions & 6 deletions mdl/executor/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"github.com/mendixlabs/mxcli/mdl/ast"
mdlerrors "github.com/mendixlabs/mxcli/mdl/errors"
"github.com/mendixlabs/mxcli/mdl/folderpath"
"github.com/mendixlabs/mxcli/mdl/types"
"github.com/mendixlabs/mxcli/model"
"github.com/mendixlabs/mxcli/sdk/domainmodel"
Expand Down Expand Up @@ -113,14 +114,9 @@ func resolveFolder(ctx *ExecContext, moduleID model.ID, folderPath string) (mode
return "", mdlerrors.NewBackend("list folders", err)
}

// Split path into parts
parts := strings.Split(folderPath, "/")
currentContainerID := moduleID

for _, part := range parts {
if part == "" {
continue
}
for _, part := range folderpath.Split(folderPath) {

// Find folder with this name under current container
var foundFolder *types.FolderInfo
Expand Down
Loading
Loading