309 lines
7.7 KiB
Go
309 lines
7.7 KiB
Go
// 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
|
|
}
|