-
Notifications
You must be signed in to change notification settings - Fork 0
fix(names): validate a VM name before it becomes a directory #148
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,13 +13,9 @@ import ( | |
| "github.com/novusedge/stoat/internal/cli/wire" | ||
| "github.com/novusedge/stoat/internal/config" | ||
| "github.com/novusedge/stoat/internal/core" | ||
| "github.com/novusedge/stoat/internal/vmname" | ||
| ) | ||
|
|
||
| // A VM name becomes a directory name under the data root, so the pattern is | ||
| // what keeps an operation inside it. Rejecting beats sanitizing: a rewrite | ||
| // hides the attempt. | ||
| var vmNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) | ||
|
|
||
| // A catalog image id never contains a separator. An absolute path here is an | ||
| // arbitrary host file read, booted as a disk. | ||
| var imageIDRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) | ||
|
|
@@ -36,24 +32,11 @@ var ( | |
| // grants an arbitrary host directory into a guest read-write. | ||
| var forbiddenPatchKeys = []string{"share", "image", "base", "iso", "console_password"} | ||
|
|
||
| // checkVMName keeps an operation inside the data root: the name becomes a | ||
| // directory there. | ||
| func checkVMName(name string) (string, error) { | ||
| if strings.TrimSpace(name) == "" { | ||
| return "", fmt.Errorf("vm name is required") | ||
| } | ||
| if name != strings.TrimSpace(name) { | ||
| return "", fmt.Errorf("vm name %q has leading or trailing whitespace", name) | ||
| } | ||
| if name == "." || name == ".." { | ||
| return "", fmt.Errorf("vm name %q is a path traversal", name) | ||
| } | ||
| if strings.ContainsAny(name, `/\`) { | ||
| return "", fmt.Errorf("vm name %q contains a path separator", name) | ||
| } | ||
| if strings.ContainsRune(name, 0) { | ||
| return "", fmt.Errorf("vm name contains a null byte") | ||
| } | ||
| if !vmNameRE.MatchString(name) { | ||
| return "", fmt.Errorf("invalid vm name %q: must match %s", name, vmNameRE) | ||
| if err := vmname.Validate(name); err != nil { | ||
| return "", err | ||
|
Comment on lines
+38
to
+39
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift Preserve access to existing legacy VM names. This applies the creation-time rule to lookup paths. Split new-name validation from legacy VM lookup validation. Keep traversal-safe checks for lookup paths, but call 🤖 Prompt for AI Agents |
||
| } | ||
| return name, nil | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,8 +11,10 @@ import ( | |
| "sort" | ||
| "strings" | ||
|
|
||
| "github.com/novusedge/stoat/internal/coreerr" | ||
| "github.com/novusedge/stoat/internal/settings" | ||
| "github.com/novusedge/stoat/internal/tomlx" | ||
| "github.com/novusedge/stoat/internal/vmname" | ||
| ) | ||
|
|
||
| // FileName is the declaration file. Its presence in os.Getwd() is the whole | ||
|
|
@@ -121,10 +123,10 @@ func Load(dir string) (*Project, error) { | |
| p := &Project{Dir: abs, Recipes: f.Recipes, Limits: f.Limits, byKey: make(map[string]VM, len(f.VMs))} | ||
| p.Name = f.Project.Name | ||
| if p.Name == "" { | ||
| p.Name = slug(filepath.Base(abs)) | ||
| p.Name = DefaultName(abs) | ||
| } | ||
| if !nameRE.MatchString(p.Name) { | ||
| return nil, fmt.Errorf("%s: project.name %q must match %s", FileName, p.Name, nameRE) | ||
| if err := ValidateProjectName(p.Name); err != nil { | ||
| return nil, fmt.Errorf("%s: project.name %w", FileName, err) | ||
| } | ||
|
|
||
| for _, key := range order(path, f.VMs) { | ||
|
|
@@ -148,6 +150,10 @@ func Load(dir string) (*Project, error) { | |
| seen := map[string]string{} | ||
| for _, v := range p.VMs { | ||
| g := p.GlobalName(v.Key) | ||
| // nameRE passes "nul", which is a Windows device at every path level. | ||
| if err := vmname.Validate(g); err != nil { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift Preserve existing VM access.
Keep 🤖 Prompt for AI Agents |
||
| return nil, fmt.Errorf("%s: vms.%s: %w", FileName, v.Key, err) | ||
| } | ||
| if first, dup := seen[g]; dup { | ||
| a, b := sortPair(first, v.Key) | ||
| return nil, fmt.Errorf("%s: vms.%s and vms.%s both resolve to %q", FileName, a, b, g) | ||
|
|
@@ -157,6 +163,25 @@ func Load(dir string) (*Project, error) { | |
| return p, nil | ||
| } | ||
|
|
||
| // DefaultName is the project name a directory implies, for a file that | ||
| // declares none. stoat init writes it and Load falls back to it, so both must | ||
| // call this and not lower-case the base name themselves: a checkout called | ||
| // "my_repo" lower-cases to a name the grammar rejects. | ||
| func DefaultName(dir string) string { return slug(filepath.Base(dir)) } | ||
|
|
||
| // ValidateProjectName reports why name cannot be project.name, or nil. The | ||
| // name prefixes every generated VM directory, so stoat init checks it before | ||
| // it writes the file, not only Load after the fact. | ||
| // | ||
| // The device-name rule does not apply here. A prefix never stands alone as a | ||
| // directory; GlobalName's result carries that check. | ||
| func ValidateProjectName(name string) error { | ||
| if !nameRE.MatchString(name) { | ||
| return fmt.Errorf("%w: %q must match %s", coreerr.ErrInvalidSpec, name, nameRE) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // Find loads the project in the current directory, if there is one. There is | ||
| // no walk-up: a command's scope must be readable from the directory the user | ||
| // typed it in, not from a parent three levels up. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| // Package vmname owns the rule for a VM name. A name becomes a directory | ||
| // under the data root, and filepath.Base of that directory reads it back, so | ||
| // every entry point that accepts a new name validates it here: internal/core's | ||
| // create path, internal/project's stoat.toml loader, and internal/mcpsrv's | ||
| // tool guards. | ||
| // | ||
| // The rule applies on every platform. A data root is portable, and a VM | ||
| // created on Linux must stay openable on Windows. | ||
| package vmname | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "regexp" | ||
| "strings" | ||
|
|
||
| "github.com/novusedge/stoat/internal/coreerr" | ||
| ) | ||
|
|
||
| // nameRE is the grammar. It excludes an empty name, a leading dash and a | ||
| // space by construction; the checks before it name the specific problem | ||
| // instead of pointing at the pattern. | ||
| var nameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) | ||
|
|
||
| // hint states what passes, appended to every rejection. | ||
| const hint = "a name is letters, digits, dot, dash or underscore, and starts with a letter or digit" | ||
|
|
||
| // reserved are the Windows device names. Windows resolves one at every path | ||
| // level, with or without an extension, so a VM directory called "nul" opens a | ||
| // device instead of a directory. | ||
| var reserved = map[string]bool{ | ||
| "CON": true, "PRN": true, "AUX": true, "NUL": true, | ||
| "COM1": true, "COM2": true, "COM3": true, "COM4": true, "COM5": true, | ||
| "COM6": true, "COM7": true, "COM8": true, "COM9": true, | ||
| "LPT1": true, "LPT2": true, "LPT3": true, "LPT4": true, "LPT5": true, | ||
| "LPT6": true, "LPT7": true, "LPT8": true, "LPT9": true, | ||
| } | ||
|
|
||
| // Validate reports why name cannot be a VM name, or nil. Every error wraps | ||
| // coreerr.ErrInvalidSpec, which internal/cli/wire reports as invalid_spec. | ||
| // | ||
| // Validate never rewrites a name. A rewrite hides the attempt from the user | ||
| // who typed it. | ||
| func Validate(name string) error { | ||
| switch { | ||
| case strings.TrimSpace(name) == "": | ||
| return errf("a vm name is required") | ||
| case name != strings.TrimSpace(name): | ||
| return errf("vm name %q has leading or trailing whitespace", name) | ||
| case name == "." || name == "..": | ||
| return errf("vm name %q is a path traversal", name) | ||
| case strings.ContainsAny(name, `/\`): | ||
| return errf("vm name %q contains a path separator", name) | ||
| case strings.ContainsRune(name, 0): | ||
| return errf("vm name %q contains a null byte", name) | ||
| case strings.HasPrefix(name, "-"): | ||
| return errf("vm name %q starts with a dash, which reads as a flag", name) | ||
| case isReserved(name): | ||
| return errf("vm name %q is a reserved device name on Windows", name) | ||
| case !nameRE.MatchString(name): | ||
| return errf("vm name %q must match %s", name, nameRE) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // isReserved matches a device name case-insensitively, with or without an | ||
| // extension. Windows also drops a trailing dot or space before it resolves a | ||
| // path, so "nul." and "nul " reach the same device. | ||
| func isReserved(name string) bool { | ||
| stem, _, _ := strings.Cut(name, ".") | ||
| stem = strings.TrimRight(stem, ". ") | ||
| return reserved[strings.ToUpper(stem)] | ||
| } | ||
|
|
||
| func errf(format string, args ...any) error { | ||
| return fmt.Errorf("%w: %s; %s", coreerr.ErrInvalidSpec, fmt.Sprintf(format, args...), hint) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: NovusEdge/stoat
Length of output: 8043
🏁 Script executed:
Repository: NovusEdge/stoat
Length of output: 50372
🏁 Script executed:
Repository: NovusEdge/stoat
Length of output: 50372
Validate the raw VM name.
strings.TrimSpace(s.Name)changes"work "to"work"beforevmname.Validateruns.planassigns the trimmed value toconfig.VM.Name, andCreatesaves that VM under"work"instead of returningErrInvalidSpec.Pass
s.Nameto the validator and add a non-empty padded-name case toTestPlanRejectsBadNames.Proposed fix
🤖 Prompt for AI Agents