11package mpr
22
33import (
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+
1633func 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+
257426func 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