package system import ( "context" "fmt" "os/exec" "path/filepath" "regexp" "strings" ) var ( usernamePattern = regexp.MustCompile(`^[a-z_][a-z0-9_-]*[$]?$`) shareNamePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) nfsOptionPattern = regexp.MustCompile(`^[a-z_]+$`) nfsClientPattern = regexp.MustCompile(`^[a-zA-Z0-9_.:/\-\*]+$`) allowedNFSOptions = map[string]bool{ "rw": true, "ro": true, "sync": true, "async": true, "root_squash": true, "no_root_squash": true, "all_squash": true, "no_all_squash": true, "subtree_check": true, "no_subtree_check": true, "secure": true, "insecure": true, "wdelay": true, "no_wdelay": true, "hide": true, "nohide": true, "crossmnt": true, "fsid": true, } ) // Run executes a command with explicit arguments (never via shell). func Run(ctx context.Context, name string, args ...string) (stdout, stderr string, err error) { cmd := exec.CommandContext(ctx, name, args...) var outBuf, errBuf strings.Builder cmd.Stdout = &outBuf cmd.Stderr = &errBuf err = cmd.Run() return outBuf.String(), errBuf.String(), err } // RunWithInput executes a command feeding stdin from the provided string. // Used for secrets (e.g. passwords) so they never appear in the argument list. func RunWithInput(ctx context.Context, stdin, name string, args ...string) (stdout, stderr string, err error) { cmd := exec.CommandContext(ctx, name, args...) cmd.Stdin = strings.NewReader(stdin) var outBuf, errBuf strings.Builder cmd.Stdout = &outBuf cmd.Stderr = &errBuf err = cmd.Run() return outBuf.String(), errBuf.String(), err } // UserExists reports whether a system user is present (via `id -u`). func UserExists(ctx context.Context, username string) bool { _, _, err := Run(ctx, "id", "-u", username) return err == nil } // ValidateUsername checks Linux username constraints. func ValidateUsername(username string) error { if username == "" || len(username) > 32 { return fmt.Errorf("invalid username length") } if !usernamePattern.MatchString(username) { return fmt.Errorf("invalid username: %q", username) } return nil } // ValidatePath ensures path is absolute and does not contain traversal. func ValidatePath(path string) error { if path == "" { return fmt.Errorf("path is required") } if !filepath.IsAbs(path) { return fmt.Errorf("path must be absolute: %q", path) } clean := filepath.Clean(path) if strings.Contains(clean, "..") { return fmt.Errorf("path must not contain .. segments: %q", path) } return nil } // ValidateShareName checks Samba share name constraints. func ValidateShareName(name string) error { if name == "" || len(name) > 80 { return fmt.Errorf("invalid share name length") } if !shareNamePattern.MatchString(name) { return fmt.Errorf("invalid share name: %q", name) } return nil } // ValidatePathAllowed validates a path and, if allowedRoots is non-empty, // ensures the path is contained within one of the configured roots. func ValidatePathAllowed(path string, allowedRoots []string) error { if err := ValidatePath(path); err != nil { return err } if len(allowedRoots) == 0 { return nil } clean := filepath.Clean(path) for _, root := range allowedRoots { root = filepath.Clean(root) if clean == root || strings.HasPrefix(clean, root+string(filepath.Separator)) { return nil } } return fmt.Errorf("path %q is not within an allowed directory", path) } // ValidateNFSClient checks an NFS client/network specifier. func ValidateNFSClient(client string) error { if client == "" || len(client) > 255 { return fmt.Errorf("invalid nfs client length") } if !nfsClientPattern.MatchString(client) { return fmt.Errorf("invalid nfs client: %q", client) } return nil } // ValidateNFSOptions checks a comma-separated list of export options against // a whitelist, rejecting anything that could break the exports file. func ValidateNFSOptions(options string) error { for _, opt := range strings.Split(options, ",") { opt = strings.TrimSpace(opt) if opt == "" { continue } key := opt if idx := strings.IndexByte(opt, '='); idx >= 0 { key = opt[:idx] } if !nfsOptionPattern.MatchString(key) || !allowedNFSOptions[key] { return fmt.Errorf("invalid nfs option: %q", opt) } } return nil }