add mosis-portal Go project with package signing and validation
This commit is contained in:
308
portal/pkg/mospkg/validator.go
Normal file
308
portal/pkg/mospkg/validator.go
Normal file
@@ -0,0 +1,308 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user