172 lines
5.2 KiB
Go
172 lines
5.2 KiB
Go
package agentci
|
|
|
|
import (
|
|
"os/exec"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
coreerr "dappco.re/go/core/log"
|
|
)
|
|
|
|
var safeNameRegex = regexp.MustCompile(`^[a-zA-Z0-9\-\_\.]+$`)
|
|
|
|
// SanitizePath ensures a filename or directory name is safe and prevents path traversal.
|
|
// Returns filepath.Base of the input after validation.
|
|
func SanitizePath(input string) (string, error) {
|
|
base := filepath.Base(input)
|
|
safeBase, err := ValidatePathElement(base)
|
|
if err != nil {
|
|
return "", coreerr.E("agentci.SanitizePath", "invalid path element", err)
|
|
}
|
|
return safeBase, nil
|
|
}
|
|
|
|
// ValidatePathElement validates a single filesystem or URL path element.
|
|
func ValidatePathElement(input string) (string, error) {
|
|
if input == "" {
|
|
return "", coreerr.E("agentci.ValidatePathElement", "path element is empty", nil)
|
|
}
|
|
if strings.TrimSpace(input) != input {
|
|
return "", coreerr.E("agentci.ValidatePathElement", "path element has leading or trailing whitespace", nil)
|
|
}
|
|
if input == "." || input == ".." {
|
|
return "", coreerr.E("agentci.ValidatePathElement", "invalid path element: "+input, nil)
|
|
}
|
|
if strings.ContainsAny(input, `/\`) || filepath.IsAbs(input) {
|
|
return "", coreerr.E("agentci.ValidatePathElement", "path element must not contain path separators", nil)
|
|
}
|
|
if !safeNameRegex.MatchString(input) {
|
|
return "", coreerr.E("agentci.ValidatePathElement", "invalid characters in path element: "+input, nil)
|
|
}
|
|
|
|
return input, nil
|
|
}
|
|
|
|
// ResolvePathWithinRoot returns a validated name and an absolute path constrained to the given root.
|
|
func ResolvePathWithinRoot(root, name string) (string, string, error) {
|
|
if root == "" {
|
|
return "", "", coreerr.E("agentci.ResolvePathWithinRoot", "root is empty", nil)
|
|
}
|
|
|
|
safeName, err := ValidatePathElement(name)
|
|
if err != nil {
|
|
return "", "", coreerr.E("agentci.ResolvePathWithinRoot", "invalid path element", err)
|
|
}
|
|
|
|
rootAbs, err := filepath.Abs(root)
|
|
if err != nil {
|
|
return "", "", coreerr.E("agentci.ResolvePathWithinRoot", "resolve root path", err)
|
|
}
|
|
rootAbs = filepath.Clean(rootAbs)
|
|
|
|
resolved := filepath.Clean(filepath.Join(rootAbs, safeName))
|
|
rel, err := filepath.Rel(rootAbs, resolved)
|
|
if err != nil {
|
|
return "", "", coreerr.E("agentci.ResolvePathWithinRoot", "compute relative path", err)
|
|
}
|
|
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
return "", "", coreerr.E("agentci.ResolvePathWithinRoot", "resolved path escapes root", nil)
|
|
}
|
|
|
|
return safeName, resolved, nil
|
|
}
|
|
|
|
// ValidateRemoteDir validates a remote POSIX directory path for SSH command usage.
|
|
func ValidateRemoteDir(input string) (string, error) {
|
|
if input == "" {
|
|
return "", coreerr.E("agentci.ValidateRemoteDir", "remote dir is empty", nil)
|
|
}
|
|
if strings.TrimSpace(input) != input {
|
|
return "", coreerr.E("agentci.ValidateRemoteDir", "remote dir has leading or trailing whitespace", nil)
|
|
}
|
|
if strings.ContainsAny(input, "\x00\r\n") {
|
|
return "", coreerr.E("agentci.ValidateRemoteDir", "remote dir contains control characters", nil)
|
|
}
|
|
if input == "/" || input == "~" {
|
|
return input, nil
|
|
}
|
|
|
|
var (
|
|
parts []string
|
|
rest string
|
|
)
|
|
switch {
|
|
case strings.HasPrefix(input, "/"):
|
|
rest = strings.TrimPrefix(input, "/")
|
|
case strings.HasPrefix(input, "~/"):
|
|
rest = strings.TrimPrefix(input, "~/")
|
|
default:
|
|
return "", coreerr.E("agentci.ValidateRemoteDir", "remote dir must be absolute or home-relative", nil)
|
|
}
|
|
|
|
for _, part := range strings.Split(rest, "/") {
|
|
if part == "" {
|
|
continue
|
|
}
|
|
safePart, err := ValidatePathElement(part)
|
|
if err != nil {
|
|
return "", coreerr.E("agentci.ValidateRemoteDir", "invalid remote dir segment", err)
|
|
}
|
|
parts = append(parts, safePart)
|
|
}
|
|
|
|
switch {
|
|
case strings.HasPrefix(input, "/"):
|
|
if len(parts) == 0 {
|
|
return "/", nil
|
|
}
|
|
return "/" + strings.Join(parts, "/"), nil
|
|
default:
|
|
if len(parts) == 0 {
|
|
return "~", nil
|
|
}
|
|
return "~/" + strings.Join(parts, "/"), nil
|
|
}
|
|
}
|
|
|
|
// JoinRemotePath joins a validated remote directory with validated path elements.
|
|
func JoinRemotePath(base string, elems ...string) (string, error) {
|
|
safeBase, err := ValidateRemoteDir(base)
|
|
if err != nil {
|
|
return "", coreerr.E("agentci.JoinRemotePath", "invalid base path", err)
|
|
}
|
|
|
|
segments := make([]string, 0, len(elems)+1)
|
|
segments = append(segments, safeBase)
|
|
for _, elem := range elems {
|
|
safeElem, err := ValidatePathElement(elem)
|
|
if err != nil {
|
|
return "", coreerr.E("agentci.JoinRemotePath", "invalid path element", err)
|
|
}
|
|
segments = append(segments, safeElem)
|
|
}
|
|
|
|
return path.Join(segments...), nil
|
|
}
|
|
|
|
// EscapeShellArg wraps a string in single quotes for safe remote shell insertion.
|
|
// Prefer exec.Command arguments over constructing shell strings where possible.
|
|
func EscapeShellArg(arg string) string {
|
|
return "'" + strings.ReplaceAll(arg, "'", "'\\''") + "'"
|
|
}
|
|
|
|
// SecureSSHCommand creates an SSH exec.Cmd with strict host key checking and batch mode.
|
|
func SecureSSHCommand(host string, remoteCmd string) *exec.Cmd {
|
|
return exec.Command("ssh",
|
|
"-o", "StrictHostKeyChecking=yes",
|
|
"-o", "BatchMode=yes",
|
|
"-o", "ConnectTimeout=10",
|
|
host,
|
|
remoteCmd,
|
|
)
|
|
}
|
|
|
|
// MaskToken returns a masked version of a token for safe logging.
|
|
func MaskToken(token string) string {
|
|
if len(token) < 8 {
|
|
return "*****"
|
|
}
|
|
return token[:4] + "****" + token[len(token)-4:]
|
|
}
|