Skip to content

Commit 49c86d8

Browse files
authored
Merge pull request #64 from mxlint/bugfix/path-names
Bugfix/path names
2 parents 0f1a6b1 + 84c694e commit 49c86d8

20 files changed

Lines changed: 1258 additions & 13 deletions

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# MxLint CLI
22

3+
[![CI](https://github.com/mxlint/mxlint-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/mxlint/mxlint-cli/actions/workflows/ci.yml)
4+
[![Release](https://img.shields.io/github/v/release/mxlint/mxlint-cli)](https://github.com/mxlint/mxlint-cli/releases/latest)
5+
[![Go Report Card](https://goreportcard.com/badge/github.com/mxlint/mxlint-cli)](https://goreportcard.com/report/github.com/mxlint/mxlint-cli)
6+
[![License](https://img.shields.io/github/license/mxlint/mxlint-cli)](LICENSE)
7+
38
A set of Command line interface tools for Mendix developers, CICD engineers and platform engineers.
49

510
> This project is in early development stage. Please use with caution. We are looking for contributors to help us improve the tools. Please create a PR with your changes. We believe in open ecosystem and open source. We are looking forward to your contributions. These can be documentation improvements, bug fixes, new features, etc.

mpr/microflow.go

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,48 @@ func transformMicroflow(mf MxDocument) MxDocument {
55
log.Infof("Transforming microflow %s", mf.Name)
66

77
cleanedData := bsonToMap(mf.Attributes)
8-
objsCollection := cleanedData["ObjectCollection"].(map[string]interface{})
9-
objs := convertToMxMicroflowObjects(objsCollection["Objects"].([]interface{}))
10-
flows := convertToMxMicroflowEdges(cleanedData["Flows"].([]interface{}))
8+
9+
// Check if ObjectCollection exists
10+
objsCollectionRaw, ok := cleanedData["ObjectCollection"]
11+
if !ok || objsCollectionRaw == nil {
12+
log.Warnf("ObjectCollection not found for microflow %s, skipping transformation", mf.Name)
13+
return mf
14+
}
15+
16+
objsCollection, ok := objsCollectionRaw.(map[string]interface{})
17+
if !ok {
18+
log.Warnf("ObjectCollection is not a map for microflow %s, skipping transformation", mf.Name)
19+
return mf
20+
}
21+
22+
objectsRaw, ok := objsCollection["Objects"]
23+
if !ok || objectsRaw == nil {
24+
log.Warnf("Objects not found in ObjectCollection for microflow %s, skipping transformation", mf.Name)
25+
return mf
26+
}
27+
28+
objects, ok := objectsRaw.([]interface{})
29+
if !ok {
30+
log.Warnf("Objects is not a slice for microflow %s, skipping transformation", mf.Name)
31+
return mf
32+
}
33+
34+
objs := convertToMxMicroflowObjects(objects)
35+
36+
// Check if Flows exists
37+
flowsRaw, ok := cleanedData["Flows"]
38+
if !ok || flowsRaw == nil {
39+
log.Warnf("Flows not found for microflow %s, skipping transformation", mf.Name)
40+
return mf
41+
}
42+
43+
flowsSlice, ok := flowsRaw.([]interface{})
44+
if !ok {
45+
log.Warnf("Flows is not a slice for microflow %s, skipping transformation", mf.Name)
46+
return mf
47+
}
48+
49+
flows := convertToMxMicroflowEdges(flowsSlice)
1150

1251
startEvent := getMxMicroflowObjectByType(objs, "Microflows$StartEvent")
1352

mpr/mpr.go

Lines changed: 201 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package mpr
22

33
import (
4+
"crypto/sha256"
45
"database/sql"
6+
"encoding/hex"
57
"fmt"
68
"io"
79
"os"
@@ -13,6 +15,21 @@ import (
1315
_ "github.com/glebarez/go-sqlite"
1416
)
1517

18+
const (
19+
// Windows is most restrictive at 260 chars
20+
MaxPathLength = 260
21+
22+
// Reserve space for base directory and separators
23+
// This leaves room for output directory path
24+
SafePathBuffer = 60
25+
26+
// Maximum safe path length for generated content
27+
MaxSafePath = MaxPathLength - SafePathBuffer // 200 chars
28+
29+
// Per-component limit (255 is filesystem limit, but we use lower for safety)
30+
MaxComponentLength = 100
31+
)
32+
1633
func ExportModel(inputDirectory string, outputDirectory string, raw bool, mode string, appstore bool) error {
1734

1835
// create tmp directory in user tmp directory
@@ -208,7 +225,7 @@ func getMxFolders(units []MxUnit) ([]MxFolder, error) {
208225
folders = append(folders, myFolder)
209226
} else if unit.ContainmentName == "" {
210227
myFolder := MxFolder{
211-
Name: ".",
228+
Name: "",
212229
ID: unit.UnitID,
213230
ParentID: unit.ContainerID,
214231
Attributes: unit.Contents,
@@ -239,9 +256,9 @@ func getMxDocumentPathRecursive(folder MxFolder, depth int) string {
239256
return ""
240257
}
241258
if folder.Parent == nil {
242-
return folder.Name
259+
return sanitizePathComponent(folder.Name)
243260
} else {
244-
return filepath.Join(getMxDocumentPathRecursive(*folder.Parent, depth-1), folder.Name)
261+
return filepath.Join(getMxDocumentPathRecursive(*folder.Parent, depth-1), sanitizePathComponent(folder.Name))
245262
}
246263
}
247264

@@ -254,6 +271,158 @@ func getMxDocumentPath(containerID string, folders []MxFolder) string {
254271
return ""
255272
}
256273

274+
// sanitizePathComponent sanitizes a single path component (folder or file name) by replacing
275+
// characters that are invalid in file systems with underscores
276+
func sanitizePathComponent(name string) string {
277+
if name == "" {
278+
return name
279+
}
280+
281+
// Characters that are invalid in Windows: < > : " / \ | ? *
282+
// Also handle control characters and other problematic characters
283+
invalidChars := []string{"<", ">", ":", "\"", "/", "\\", "|", "?", "*", ".."}
284+
285+
sanitized := name
286+
for _, char := range invalidChars {
287+
sanitized = strings.ReplaceAll(sanitized, char, "_")
288+
}
289+
290+
// Replace control characters (ASCII 0-31) and DEL (127)
291+
// Also replace newlines, carriage returns, tabs, and null bytes
292+
result := strings.Builder{}
293+
for _, r := range sanitized {
294+
if r < 32 || r == 127 {
295+
result.WriteRune('_')
296+
} else {
297+
result.WriteRune(r)
298+
}
299+
}
300+
sanitized = result.String()
301+
302+
// Trim leading/trailing spaces and dots (problematic on Windows)
303+
sanitized = strings.Trim(sanitized, " .")
304+
305+
// If the name is now empty after trimming, use a default
306+
if sanitized == "" {
307+
sanitized = "unnamed"
308+
}
309+
310+
// Check for Windows reserved names (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
311+
// These are case-insensitive on Windows
312+
upper := strings.ToUpper(sanitized)
313+
reservedNames := []string{"CON", "PRN", "AUX", "NUL"}
314+
for _, reserved := range reservedNames {
315+
if upper == reserved {
316+
sanitized = "_" + sanitized
317+
break
318+
}
319+
}
320+
// Check COM1-COM9 and LPT1-LPT9
321+
if len(upper) == 4 {
322+
prefix := upper[:3]
323+
if (prefix == "COM" || prefix == "LPT") && upper[3] >= '1' && upper[3] <= '9' {
324+
sanitized = "_" + sanitized
325+
}
326+
}
327+
328+
// Enforce maximum component length
329+
if len(sanitized) > MaxComponentLength {
330+
sanitized = truncatePathComponent(sanitized, MaxComponentLength)
331+
}
332+
333+
return sanitized
334+
}
335+
336+
// sanitizePath sanitizes a full path by sanitizing each component
337+
func sanitizePath(path string) string {
338+
// Split the path into components
339+
components := strings.Split(path, string(filepath.Separator))
340+
341+
// Sanitize each component
342+
for i, component := range components {
343+
components[i] = sanitizePathComponent(component)
344+
}
345+
346+
// Rejoin the path
347+
return filepath.Join(components...)
348+
}
349+
350+
// truncatePathComponent truncates a path component to maxLen while maintaining uniqueness
351+
// If truncation is needed, it appends a hash to ensure uniqueness
352+
func truncatePathComponent(name string, maxLen int) string {
353+
if len(name) <= maxLen {
354+
return name
355+
}
356+
357+
// Create a short hash of the full name for uniqueness
358+
hash := sha256.Sum256([]byte(name))
359+
hashStr := hex.EncodeToString(hash[:])[:8] // Use first 8 chars of hash
360+
361+
// Reserve space for hash and separator
362+
maxTextLen := maxLen - len(hashStr) - 1 // -1 for underscore
363+
364+
if maxTextLen < 1 {
365+
// If maxLen is too small, just use the hash
366+
return hashStr[:maxLen]
367+
}
368+
369+
// Truncate and append hash
370+
return name[:maxTextLen] + "_" + hashStr
371+
}
372+
373+
// max returns the maximum of two integers
374+
func max(a, b int) int {
375+
if a > b {
376+
return a
377+
}
378+
return b
379+
}
380+
381+
// validatePathLength checks if the full path would exceed limits and adjusts if needed
382+
func validatePathLength(basePath string, relativePath string, filename string) (string, string, error) {
383+
fullPath := filepath.Join(basePath, relativePath, filename)
384+
385+
if len(fullPath) <= MaxSafePath {
386+
return relativePath, filename, nil
387+
}
388+
389+
// Path is too long, need to adjust
390+
log.Warnf("Path exceeds safe length (%d chars): %s", len(fullPath), fullPath)
391+
392+
// Strategy: Truncate path components starting from the deepest
393+
components := strings.Split(relativePath, string(filepath.Separator))
394+
395+
// Calculate how much we need to save
396+
excess := len(fullPath) - MaxSafePath
397+
398+
// Try to shorten components from the end (deepest folders)
399+
for i := len(components) - 1; i >= 0 && excess > 0; i-- {
400+
oldLen := len(components[i])
401+
targetLen := max(10, oldLen-excess) // Keep at least 10 chars if possible
402+
403+
if oldLen > targetLen {
404+
components[i] = truncatePathComponent(components[i], targetLen)
405+
excess -= (oldLen - len(components[i]))
406+
}
407+
}
408+
409+
// If still too long, truncate the filename
410+
if excess > 0 {
411+
maxFilenameLen := len(filename) - excess - 10 // Reserve 10 chars minimum
412+
if maxFilenameLen < 10 {
413+
maxFilenameLen = 10
414+
}
415+
filename = truncatePathComponent(filename, maxFilenameLen)
416+
}
417+
418+
newRelativePath := filepath.Join(components...)
419+
newFullPath := filepath.Join(basePath, newRelativePath, filename)
420+
421+
log.Warnf("Adjusted path from %d to %d chars", len(fullPath), len(newFullPath))
422+
423+
return newRelativePath, filename, nil
424+
}
425+
257426
func getMxDocuments(units []MxUnit, folders []MxFolder, mode string) ([]MxDocument, error) {
258427
var documents []MxDocument
259428
documentTypes := []string{"ProjectDocuments", "DomainModel", "ModuleSettings", "ModuleSecurity", "Documents"}
@@ -302,19 +471,41 @@ func exportUnits(inputDirectory string, outputDirectory string, raw bool, mode s
302471

303472
for _, document := range documents {
304473
// write document
305-
directory := filepath.Join(outputDirectory, document.Path)
474+
// Sanitize the document path to handle invalid characters
475+
sanitizedPath := sanitizePath(document.Path)
476+
if sanitizedPath != document.Path {
477+
log.Warnf("Sanitized path: '%s' -> '%s'", document.Path, sanitizedPath)
478+
}
479+
480+
// Sanitize the document name to handle invalid characters
481+
sanitizedName := sanitizePathComponent(document.Name)
482+
sanitizedType := sanitizePathComponent(document.Type)
483+
if sanitizedName != document.Name || sanitizedType != document.Type {
484+
log.Debugf("Sanitized name: '%s' -> '%s', type: '%s' -> '%s'", document.Name, sanitizedName, document.Type, sanitizedType)
485+
}
486+
487+
fname := fmt.Sprintf("%s.%s.yaml", sanitizedName, sanitizedType)
488+
if document.Name == "" {
489+
fname = fmt.Sprintf("%s.yaml", sanitizedType)
490+
}
491+
492+
// Validate and adjust path length to prevent exceeding OS limits
493+
adjustedPath, adjustedFilename, err := validatePathLength(outputDirectory, sanitizedPath, fname)
494+
if err != nil {
495+
return fmt.Errorf("error adjusting path length: %v", err)
496+
}
497+
498+
directory := filepath.Join(outputDirectory, adjustedPath)
499+
306500
// ensure directory exists
307501
if _, err := os.Stat(directory); os.IsNotExist(err) {
308502
if err := os.MkdirAll(directory, 0755); err != nil {
309503
return fmt.Errorf("error creating directory: %v", err)
310504
}
311505
}
312-
fname := fmt.Sprintf("%s.%s.yaml", document.Name, document.Type)
313-
if document.Name == "" {
314-
fname = fmt.Sprintf("%s.yaml", document.Type)
315-
}
506+
316507
attributes := cleanData(document.Attributes, raw)
317-
err = writeFile(filepath.Join(directory, fname), attributes)
508+
err = writeFile(filepath.Join(directory, adjustedFilename), attributes)
318509
if err != nil {
319510
log.Errorf("Error writing file: %v", err)
320511
return err
@@ -364,7 +555,7 @@ func removeAppstoreModules(tmpDir string, modules []MxModule) error {
364555
// Check if module is an appstore module by looking at its attributes
365556
if isAppstoreModule(module) {
366557
moduleDir := filepath.Join(tmpDir, module.Name)
367-
log.Infof("Discarding appstore module: %s", moduleDir)
558+
log.Warnf("Ignoring appstore module: %s", moduleDir)
368559
if err := os.RemoveAll(moduleDir); err != nil {
369560
return fmt.Errorf("error removing appstore module %s: %v", module.Name, err)
370561
}

0 commit comments

Comments
 (0)