Files
gibidify/gibidiutils/paths.go
Ismo Vuorinen 3f65b813bd feat: update go to 1.25, add permissions and envs (#49)
* chore(ci): update go to 1.25, add permissions and envs
* fix(ci): update pr-lint.yml
* chore: update go, fix linting
* fix: tests and linting
* fix(lint): lint fixes, renovate should now pass
* fix: updates, security upgrades
* chore: workflow updates, lint
* fix: more lint, checkmake, and other fixes
* fix: more lint, convert scripts to POSIX compliant
* fix: simplify codeql workflow
* tests: increase test coverage, fix found issues
* fix(lint): editorconfig checking, add to linters
* fix(lint): shellcheck, add to linters
* fix(lint): apply cr comment suggestions
* fix(ci): remove step-security/harden-runner
* fix(lint): remove duplication, apply cr fixes
* fix(ci): tests in CI/CD pipeline
* chore(lint): deduplication of strings
* fix(lint): apply cr comment suggestions
* fix(ci): actionlint
* fix(lint): apply cr comment suggestions
* chore: lint, add deps management
2025-10-10 12:14:42 +03:00

312 lines
7.8 KiB
Go

// Package gibidiutils provides common utility functions for gibidify.
package gibidiutils
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// EscapeForMarkdown sanitizes a string for safe use in Markdown code-fence and header lines.
// It replaces backticks with backslash-escaped backticks and removes/collapses newlines.
func EscapeForMarkdown(s string) string {
// Escape backticks
safe := strings.ReplaceAll(s, "`", "\\`")
// Remove newlines (collapse to space)
safe = strings.ReplaceAll(safe, "\n", " ")
safe = strings.ReplaceAll(safe, "\r", " ")
return safe
}
// GetAbsolutePath returns the absolute path for the given path.
// It wraps filepath.Abs with consistent error handling.
func GetAbsolutePath(path string) (string, error) {
abs, err := filepath.Abs(path)
if err != nil {
return "", fmt.Errorf("failed to get absolute path for %s: %w", path, err)
}
return abs, nil
}
// GetBaseName returns the base name for the given path, handling special cases.
func GetBaseName(absPath string) string {
baseName := filepath.Base(absPath)
if baseName == "." || baseName == "" {
return "output"
}
return baseName
}
// checkPathTraversal checks for path traversal patterns and returns an error if found.
func checkPathTraversal(path, context string) error {
// Normalize separators without cleaning (to preserve ..)
normalized := filepath.ToSlash(path)
// Split into components
components := strings.Split(normalized, "/")
// Check each component for exact ".." match
for _, component := range components {
if component == ".." {
return NewStructuredError(
ErrorTypeValidation,
CodeValidationPath,
fmt.Sprintf("path traversal attempt detected in %s", context),
path,
map[string]interface{}{
"original_path": path,
},
)
}
}
return nil
}
// cleanAndResolveAbsPath cleans a path and resolves it to an absolute path.
func cleanAndResolveAbsPath(path, context string) (string, error) {
cleaned := filepath.Clean(path)
abs, err := filepath.Abs(cleaned)
if err != nil {
return "", NewStructuredError(
ErrorTypeFileSystem,
CodeFSPathResolution,
fmt.Sprintf("cannot resolve %s", context),
path,
map[string]interface{}{
"error": err.Error(),
},
)
}
return abs, nil
}
// evalSymlinksOrStructuredError wraps filepath.EvalSymlinks with structured error handling.
func evalSymlinksOrStructuredError(path, context, original string) (string, error) {
eval, err := filepath.EvalSymlinks(path)
if err != nil {
return "", NewStructuredError(
ErrorTypeValidation,
CodeValidationPath,
fmt.Sprintf("cannot resolve symlinks for %s", context),
original,
map[string]interface{}{
"resolved_path": path,
"context": context,
"error": err.Error(),
},
)
}
return eval, nil
}
// validateWorkingDirectoryBoundary checks if the given absolute path escapes the working directory.
func validateWorkingDirectoryBoundary(abs, path string) error {
cwd, err := os.Getwd()
if err != nil {
return NewStructuredError(
ErrorTypeFileSystem,
CodeFSPathResolution,
"cannot get current working directory",
path,
map[string]interface{}{
"error": err.Error(),
},
)
}
cwdAbs, err := filepath.Abs(cwd)
if err != nil {
return NewStructuredError(
ErrorTypeFileSystem,
CodeFSPathResolution,
"cannot resolve current working directory",
path,
map[string]interface{}{
"error": err.Error(),
},
)
}
absEval, err := evalSymlinksOrStructuredError(abs, "source path", path)
if err != nil {
return err
}
cwdEval, err := evalSymlinksOrStructuredError(cwdAbs, "working directory", path)
if err != nil {
return err
}
rel, err := filepath.Rel(cwdEval, absEval)
if err != nil {
return NewStructuredError(
ErrorTypeValidation,
CodeValidationPath,
"cannot determine relative path",
path,
map[string]interface{}{
"resolved_path": absEval,
"working_dir": cwdEval,
"error": err.Error(),
},
)
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return NewStructuredError(
ErrorTypeValidation,
CodeValidationPath,
"source path attempts to access directories outside current working directory",
path,
map[string]interface{}{
"resolved_path": absEval,
"working_dir": cwdEval,
"relative_path": rel,
},
)
}
return nil
}
// ValidateSourcePath validates a source directory path for security.
// It ensures the path exists, is a directory, and doesn't contain path traversal attempts.
//
//revive:disable-next-line:function-length
func ValidateSourcePath(path string) error {
if path == "" {
return NewValidationError(CodeValidationRequired, "source path is required")
}
// Check for path traversal patterns before cleaning
if err := checkPathTraversal(path, "source path"); err != nil {
return err
}
// Clean and get absolute path
abs, err := cleanAndResolveAbsPath(path, "source path")
if err != nil {
return err
}
cleaned := filepath.Clean(path)
// Ensure the resolved path is within or below the current working directory for relative paths
if !filepath.IsAbs(path) {
if err := validateWorkingDirectoryBoundary(abs, path); err != nil {
return err
}
}
// Check if path exists and is a directory
info, err := os.Stat(cleaned)
if err != nil {
if os.IsNotExist(err) {
return NewFileSystemError(CodeFSNotFound, "source directory does not exist").WithFilePath(path)
}
return NewStructuredError(
ErrorTypeFileSystem,
CodeFSAccess,
"cannot access source directory",
path,
map[string]interface{}{
"error": err.Error(),
},
)
}
if !info.IsDir() {
return NewStructuredError(
ErrorTypeValidation,
CodeValidationPath,
"source path must be a directory",
path,
map[string]interface{}{
"is_file": true,
},
)
}
return nil
}
// ValidateDestinationPath validates a destination file path for security.
// It ensures the path doesn't contain path traversal attempts and the parent directory exists.
func ValidateDestinationPath(path string) error {
if path == "" {
return NewValidationError(CodeValidationRequired, "destination path is required")
}
// Check for path traversal patterns before cleaning
if err := checkPathTraversal(path, "destination path"); err != nil {
return err
}
// Get absolute path to ensure it's not trying to escape current working directory
abs, err := cleanAndResolveAbsPath(path, "destination path")
if err != nil {
return err
}
// Ensure the destination is not a directory
if info, err := os.Stat(abs); err == nil && info.IsDir() {
return NewStructuredError(
ErrorTypeValidation,
CodeValidationPath,
"destination cannot be a directory",
path,
map[string]interface{}{
"is_directory": true,
},
)
}
// Check if parent directory exists and is writable
parentDir := filepath.Dir(abs)
if parentInfo, err := os.Stat(parentDir); err != nil {
if os.IsNotExist(err) {
return NewStructuredError(
ErrorTypeFileSystem,
CodeFSNotFound,
"destination parent directory does not exist",
path,
map[string]interface{}{
"parent_dir": parentDir,
},
)
}
return NewStructuredError(
ErrorTypeFileSystem,
CodeFSAccess,
"cannot access destination parent directory",
path,
map[string]interface{}{
"parent_dir": parentDir,
"error": err.Error(),
},
)
} else if !parentInfo.IsDir() {
return NewStructuredError(
ErrorTypeValidation,
CodeValidationPath,
"destination parent is not a directory",
path,
map[string]interface{}{
"parent_dir": parentDir,
},
)
}
return nil
}
// ValidateConfigPath validates a configuration file path for security.
// It ensures the path doesn't contain path traversal attempts.
func ValidateConfigPath(path string) error {
if path == "" {
return nil // Empty path is allowed for config
}
// Check for path traversal patterns before cleaning
return checkPathTraversal(path, "config path")
}