argo-cd/util/app/path/path.go
Nitish Kumar 6ba0727217
fix: improve error message when hydrateTo sync path does not exist yet (#27336)
Signed-off-by: nitishfy <justnitish06@gmail.com>
2026-04-14 13:50:52 +00:00

191 lines
6 KiB
Go

package path
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1"
"github.com/argoproj/argo-cd/v3/util/io/files"
"github.com/argoproj/argo-cd/v3/util/security"
)
const ErrMessageAppPathDoesNotExist = "app path does not exist"
func Path(root, path string) (string, error) {
if filepath.IsAbs(path) {
return "", fmt.Errorf("%s: app path is absolute", path)
}
appPath := filepath.Join(root, path)
if !strings.HasPrefix(appPath, filepath.Clean(root)) {
return "", fmt.Errorf("%s: app path outside root", path)
}
info, err := os.Stat(appPath)
if os.IsNotExist(err) {
return "", fmt.Errorf("%s: %s", path, ErrMessageAppPathDoesNotExist)
}
if err != nil {
return "", err
}
if !info.IsDir() {
return "", fmt.Errorf("%s: app path is not a directory", path)
}
return appPath, nil
}
type OutOfBoundsSymlinkError struct {
File string
Err error
}
func (e *OutOfBoundsSymlinkError) Error() string {
return "out of bounds symlink found"
}
// CheckOutOfBoundsSymlinks determines if basePath contains any symlinks that
// are absolute or point to a path outside of the basePath. If found, an
// OutOfBoundsSymlinkError is returned.
func CheckOutOfBoundsSymlinks(basePath string, skipPaths ...string) error {
absBasePath, err := filepath.Abs(basePath)
if err != nil {
return fmt.Errorf("failed to get absolute path: %w", err)
}
skipPathsSet := map[string]bool{}
for _, p := range skipPaths {
skipPathsSet[filepath.Join(absBasePath, p)] = true
}
return filepath.Walk(absBasePath, func(path string, info os.FileInfo, err error) error {
if err != nil {
// Ignore "no such file or directory" errors that can happen with
// temporary files such as .git/*.lock
if errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("failed to walk for symlinks in %s: %w", absBasePath, err)
}
if skipPathsSet[path] {
return filepath.SkipDir
}
if files.IsSymlink(info) {
// We don't use filepath.EvalSymlinks because it fails without returning a path
// if the target doesn't exist.
linkTarget, err := os.Readlink(path)
if err != nil {
return fmt.Errorf("failed to read link %s: %w", path, err)
}
// get the path of the symlink relative to basePath, used for error description
linkRelPath, err := filepath.Rel(absBasePath, path)
if err != nil {
return fmt.Errorf("failed to get relative path for symlink: %w", err)
}
// deny all absolute symlinks
if filepath.IsAbs(linkTarget) {
return &OutOfBoundsSymlinkError{File: linkRelPath}
}
// get the parent directory of the symlink
currentDir := filepath.Dir(path)
// walk each part of the symlink target to make sure it never leaves basePath
parts := strings.SplitSeq(linkTarget, string(os.PathSeparator))
for part := range parts {
newDir := filepath.Join(currentDir, part)
rel, err := filepath.Rel(absBasePath, newDir)
if err != nil {
return fmt.Errorf("failed to get relative path for symlink target: %w", err)
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
// return an error so we don't keep traversing the tree
return &OutOfBoundsSymlinkError{File: linkRelPath}
}
currentDir = newDir
}
}
return nil
})
}
// GetSourceRefreshPaths returns the list of paths that should trigger a refresh for an application.
// The source parameter influences the returned refresh paths:
// - if source hydrator configured AND source is syncSource: use sync source path (ignores annotation)
// - if source hydrator configured AND source is drySource WITH annotation: use annotation paths with drySource base
// - if source hydrator not configured: use annotation paths with source base, or empty if no annotation
func GetSourceRefreshPaths(app *v1alpha1.Application, source v1alpha1.ApplicationSource) []string {
annotationPaths, hasAnnotation := app.Annotations[v1alpha1.AnnotationKeyManifestGeneratePaths]
if app.Spec.SourceHydrator != nil {
syncSource := app.Spec.SourceHydrator.GetSyncSource()
// if source is syncSource use the source path
if (source).Equals(&syncSource) {
return []string{source.Path}
}
}
var paths []string
if hasAnnotation && annotationPaths != "" {
for item := range strings.SplitSeq(annotationPaths, ";") {
// Trim whitespace because annotation values may contain spaces around
// separators (e.g. ".; /path"). Without trimming, paths like " /path"
// are not treated as absolute and empty/space-only entries may result
// in duplicate or incorrect refresh paths.
item = strings.TrimSpace(item)
// skip empty paths
if item == "" {
continue
}
// if absolute path, add as is
if filepath.IsAbs(item) {
paths = append(paths, item[1:])
continue
}
// add the path relative to the source path
paths = append(paths, filepath.Clean(filepath.Join(source.Path, item)))
}
}
return paths
}
// AppFilesHaveChanged returns true if any of the changed files are under the given refresh paths
// If refreshPaths or changedFiles are empty, it will always return true
func AppFilesHaveChanged(refreshPaths []string, changedFiles []string) bool {
// an empty slice of changed files means that the payload didn't include a list
// of changed files and we have to assume that a refresh is required
if len(changedFiles) == 0 {
return true
}
if len(refreshPaths) == 0 {
// Apps without a given refreshed paths always be refreshed, regardless of changed files
// this is the "default" behavior
return true
}
// At last one changed file must be under refresh path
for _, f := range changedFiles {
f = ensureAbsPath(f)
for _, item := range refreshPaths {
item = ensureAbsPath(item)
if f == item {
return true
} else if _, err := security.EnforceToCurrentRoot(item, f); err == nil {
return true
} else if matched, err := filepath.Match(item, f); err == nil && matched {
return true
}
}
}
return false
}
func ensureAbsPath(input string) string {
if !filepath.IsAbs(input) {
return string(filepath.Separator) + input
}
return input
}