// Package mospkg provides functionality for Mosis app packages (.mosis files) package mospkg import ( "archive/zip" "fmt" "io" "path/filepath" "strings" ) // Size limits const ( MaxPackageSize = 50 * 1024 * 1024 // 50 MB MaxFileSize = 10 * 1024 * 1024 // 10 MB MaxFileCount = 1000 MaxPathLength = 256 MaxManifestSize = 64 * 1024 // 64 KB ) // ValidationError represents a validation error type ValidationError struct { Code string `json:"code"` Message string `json:"message"` File string `json:"file,omitempty"` Line int `json:"line,omitempty"` } // ValidationWarning represents a non-blocking validation warning type ValidationWarning struct { Code string `json:"code"` Message string `json:"message"` File string `json:"file,omitempty"` } // ValidationResult holds the result of package validation type ValidationResult struct { Valid bool `json:"valid"` Errors []ValidationError `json:"errors,omitempty"` Warnings []ValidationWarning `json:"warnings,omitempty"` Manifest *Manifest `json:"manifest,omitempty"` } // AllowedExtensions are file extensions permitted in packages var AllowedExtensions = map[string]bool{ ".rml": true, ".rcss": true, ".lua": true, ".png": true, ".jpg": true, ".jpeg": true, ".tga": true, ".webp": true, ".ttf": true, ".otf": true, ".json": true, ".ogg": true, ".wav": true, ".mp3": true, } // ForbiddenExtensions are file extensions not allowed in packages var ForbiddenExtensions = map[string]bool{ ".exe": true, ".dll": true, ".so": true, ".dylib": true, ".sh": true, ".bat": true, ".ps1": true, ".py": true, ".js": true, ".zip": true, ".tar": true, ".gz": true, } // ValidatePackage validates a .mosis package file func ValidatePackage(path string) (*ValidationResult, error) { result := &ValidationResult{Valid: true} // Open ZIP archive reader, err := zip.OpenReader(path) if err != nil { result.Valid = false result.Errors = append(result.Errors, ValidationError{ Code: "INVALID_ZIP", Message: fmt.Sprintf("Failed to open package: %v", err), }) return result, nil } defer reader.Close() // Check file count if len(reader.File) > MaxFileCount { result.Valid = false result.Errors = append(result.Errors, ValidationError{ Code: "TOO_MANY_FILES", Message: fmt.Sprintf("Package contains %d files, maximum is %d", len(reader.File), MaxFileCount), }) return result, nil } var manifestData []byte seenFiles := make(map[string]bool) var totalSize int64 for _, file := range reader.File { name := file.Name // Check for path traversal if strings.Contains(name, "..") { result.Valid = false result.Errors = append(result.Errors, ValidationError{ Code: "PATH_TRAVERSAL", Message: "Path traversal detected", File: name, }) continue } // Check for absolute paths if filepath.IsAbs(name) { result.Valid = false result.Errors = append(result.Errors, ValidationError{ Code: "ABSOLUTE_PATH", Message: "Absolute path not allowed", File: name, }) continue } // Check path length if len(name) > MaxPathLength { result.Valid = false result.Errors = append(result.Errors, ValidationError{ Code: "PATH_TOO_LONG", Message: fmt.Sprintf("Path exceeds %d characters", MaxPathLength), File: name, }) continue } // Check for duplicates normalizedName := strings.ToLower(name) if seenFiles[normalizedName] { result.Valid = false result.Errors = append(result.Errors, ValidationError{ Code: "DUPLICATE_FILE", Message: "Duplicate file detected", File: name, }) continue } seenFiles[normalizedName] = true // Skip directories if file.FileInfo().IsDir() { continue } // Check individual file size if file.UncompressedSize64 > uint64(MaxFileSize) { result.Valid = false result.Errors = append(result.Errors, ValidationError{ Code: "FILE_TOO_LARGE", Message: fmt.Sprintf("File exceeds %d MB limit", MaxFileSize/(1024*1024)), File: name, }) continue } totalSize += int64(file.UncompressedSize64) // Check file extension ext := strings.ToLower(filepath.Ext(name)) if ForbiddenExtensions[ext] { result.Valid = false result.Errors = append(result.Errors, ValidationError{ Code: "FORBIDDEN_EXTENSION", Message: fmt.Sprintf("File type %s is not allowed", ext), File: name, }) continue } // Skip META-INF files for extension check if !strings.HasPrefix(name, "META-INF/") && ext != "" && !AllowedExtensions[ext] { result.Warnings = append(result.Warnings, ValidationWarning{ Code: "UNKNOWN_EXTENSION", Message: fmt.Sprintf("Unknown file extension: %s", ext), File: name, }) } // Read manifest.json if name == "manifest.json" { rc, err := file.Open() if err != nil { result.Valid = false result.Errors = append(result.Errors, ValidationError{ Code: "MANIFEST_READ_ERROR", Message: fmt.Sprintf("Failed to read manifest: %v", err), }) continue } manifestData, err = io.ReadAll(io.LimitReader(rc, MaxManifestSize)) rc.Close() if err != nil { result.Valid = false result.Errors = append(result.Errors, ValidationError{ Code: "MANIFEST_READ_ERROR", Message: fmt.Sprintf("Failed to read manifest: %v", err), }) } } } // Check total size if totalSize > MaxPackageSize { result.Valid = false result.Errors = append(result.Errors, ValidationError{ Code: "PACKAGE_TOO_LARGE", Message: fmt.Sprintf("Package exceeds %d MB limit", MaxPackageSize/(1024*1024)), }) } // Validate manifest if manifestData == nil { result.Valid = false result.Errors = append(result.Errors, ValidationError{ Code: "MISSING_MANIFEST", Message: "Package is missing manifest.json", }) } else { manifest, err := ParseManifest(manifestData) if err != nil { result.Valid = false result.Errors = append(result.Errors, ValidationError{ Code: "INVALID_MANIFEST", Message: fmt.Sprintf("Invalid manifest.json: %v", err), }) } else { result.Manifest = manifest manifestErrors := manifest.Validate() if len(manifestErrors) > 0 { result.Valid = false result.Errors = append(result.Errors, manifestErrors...) } // Check that entry point exists entryExists := false for _, file := range reader.File { if file.Name == manifest.Entry { entryExists = true break } } if !entryExists { result.Valid = false result.Errors = append(result.Errors, ValidationError{ Code: "MISSING_ENTRY", Message: fmt.Sprintf("Entry point file not found: %s", manifest.Entry), }) } // Check icons if result.Manifest.Icons.Size32 != "" { if !fileExistsInZip(reader, result.Manifest.Icons.Size32) { result.Errors = append(result.Errors, ValidationError{ Code: "MISSING_ICON", Message: "Icon file not found", File: result.Manifest.Icons.Size32, }) } } if result.Manifest.Icons.Size64 != "" { if !fileExistsInZip(reader, result.Manifest.Icons.Size64) { result.Errors = append(result.Errors, ValidationError{ Code: "MISSING_ICON", Message: "Icon file not found", File: result.Manifest.Icons.Size64, }) } } if result.Manifest.Icons.Size128 != "" { if !fileExistsInZip(reader, result.Manifest.Icons.Size128) { result.Errors = append(result.Errors, ValidationError{ Code: "MISSING_ICON", Message: "Icon file not found", File: result.Manifest.Icons.Size128, }) } } } } return result, nil } func fileExistsInZip(reader *zip.ReadCloser, name string) bool { for _, file := range reader.File { if file.Name == name { return true } } return false }