feat: add portable directory safeguards
This commit is contained in:
@@ -3,6 +3,7 @@ package domain
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -90,3 +91,29 @@ func TestNormalizeProxyNameRequiresNonEmptyDisplayText(t *testing.T) {
|
||||
t.Fatalf("empty proxy name error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeInstanceNameRejectsUnsafeWindowsNames(t *testing.T) {
|
||||
if got, err := NormalizeInstanceName(" 审核 Edge "); err != nil || got != "审核 Edge" {
|
||||
t.Fatalf("NormalizeInstanceName() = %q, %v", got, err)
|
||||
}
|
||||
for _, value := range []string{"", "CON", "aux.txt", "审核?", "审核.", "审核\\Edge"} {
|
||||
if _, err := NormalizeInstanceName(value); err == nil {
|
||||
t.Fatalf("unsafe instance name %q was accepted", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceProfileDirectoryNameIsReadableAndStablePerID(t *testing.T) {
|
||||
first, err := InstanceProfileDirectoryName("审核 Edge", "instance-one")
|
||||
if err != nil || !strings.HasPrefix(first, "instance-审核 Edge-") {
|
||||
t.Fatalf("first directory name = %q, %v", first, err)
|
||||
}
|
||||
second, err := InstanceProfileDirectoryName("审核 Edge", "instance-two")
|
||||
if err != nil || second == first {
|
||||
t.Fatalf("second directory name = %q, %v", second, err)
|
||||
}
|
||||
long, err := InstanceProfileDirectoryName(strings.Repeat("审", 80), "instance-long")
|
||||
if err != nil || len(long) > len("instance-")+64+1+8 {
|
||||
t.Fatalf("long directory name = %q, %v", long, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const maxInstanceNameRunes = 80
|
||||
|
||||
var errInvalidInstanceName = errors.New("invalid instance name")
|
||||
|
||||
// NormalizeInstanceName validates the user-visible instance name before it is
|
||||
// stored or used to derive a Windows profile directory. The validation keeps
|
||||
// the display name readable instead of silently rewriting user input.
|
||||
func NormalizeInstanceName(value string) (string, error) {
|
||||
name := strings.TrimSpace(value)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("%w: name is required", errInvalidInstanceName)
|
||||
}
|
||||
if utf8.RuneCountInString(name) > maxInstanceNameRunes {
|
||||
return "", fmt.Errorf("%w: name is too long", errInvalidInstanceName)
|
||||
}
|
||||
if strings.HasSuffix(name, ".") {
|
||||
return "", fmt.Errorf("%w: name cannot end with a period", errInvalidInstanceName)
|
||||
}
|
||||
for _, value := range name {
|
||||
if value < 0x20 || strings.ContainsRune(`<>:"/\\|?*`, value) {
|
||||
return "", fmt.Errorf("%w: name contains a Windows-reserved character", errInvalidInstanceName)
|
||||
}
|
||||
}
|
||||
base := strings.ToUpper(strings.Split(name, ".")[0])
|
||||
if isReservedWindowsDeviceName(base) {
|
||||
return "", fmt.Errorf("%w: name is a reserved Windows device name", errInvalidInstanceName)
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// InstanceProfileDirectoryName derives a compact, readable, and unique
|
||||
// profile directory component. The stable hash keeps paths distinct even
|
||||
// when display names share a truncated prefix.
|
||||
func InstanceProfileDirectoryName(name, instanceID string) (string, error) {
|
||||
name, err := NormalizeInstanceName(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
instanceID = strings.TrimSpace(instanceID)
|
||||
if instanceID == "" {
|
||||
return "", fmt.Errorf("%w: instance ID is required", errInvalidInstanceName)
|
||||
}
|
||||
segment := truncateUTF8(name, 64)
|
||||
hash := fnv.New32a()
|
||||
_, _ = hash.Write([]byte(instanceID))
|
||||
return fmt.Sprintf("instance-%s-%08x", segment, hash.Sum32()), nil
|
||||
}
|
||||
|
||||
func truncateUTF8(value string, limit int) string {
|
||||
if len(value) <= limit {
|
||||
return value
|
||||
}
|
||||
count := 0
|
||||
for index := range value {
|
||||
if index > limit {
|
||||
break
|
||||
}
|
||||
count = index
|
||||
}
|
||||
if count == 0 {
|
||||
return value
|
||||
}
|
||||
return value[:count]
|
||||
}
|
||||
|
||||
func isReservedWindowsDeviceName(value string) bool {
|
||||
switch value {
|
||||
case "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user