package volume import ( "encoding/json" "fmt" "path/filepath" "strings" "github.com/alexei/docker-watcher/internal/store" ) // ResolveParams holds the parameters needed to resolve a volume's host path. type ResolveParams struct { BasePath string ProjectName string StageName string // required for instance and stage scopes ImageTag string // required for instance scope AllowedVolumePaths string // JSON array of allowed absolute paths (from settings) } // ResolvePath returns the absolute host path for a volume based on its scope. // Returns an error for ephemeral volumes (no host path) or missing parameters. func ResolvePath(vol store.Volume, params ResolveParams) (string, error) { scope := vol.Scope if scope == "" { switch vol.Mode { case "isolated": scope = "instance" default: scope = "project" } } if scope == "ephemeral" { return "", fmt.Errorf("ephemeral volumes have no host path") } if scope == "absolute" { return resolveAbsolute(vol.Source, params.AllowedVolumePaths) } switch scope { case "instance": if params.StageName == "" || params.ImageTag == "" { return "", fmt.Errorf("instance scope requires stage and tag parameters") } return filepath.Join(params.BasePath, params.ProjectName, fmt.Sprintf("%s-%s", params.StageName, params.ImageTag), vol.Source), nil case "stage": if params.StageName == "" { return "", fmt.Errorf("stage scope requires stage parameter") } return filepath.Join(params.BasePath, params.ProjectName, params.StageName, vol.Source), nil case "project": return filepath.Join(params.BasePath, params.ProjectName, vol.Source), nil case "project_named": return filepath.Join(params.BasePath, params.ProjectName, "_named", vol.Name, vol.Source), nil case "named": return filepath.Join(params.BasePath, "_named", vol.Name, vol.Source), nil default: return filepath.Join(params.BasePath, params.ProjectName, vol.Source), nil } } // resolveAbsolute validates that the source path is under one of the allowed prefixes. func resolveAbsolute(source, allowedPathsJSON string) (string, error) { if source == "" { return "", fmt.Errorf("absolute scope requires a source path") } cleaned := filepath.Clean(source) if !filepath.IsAbs(cleaned) { return "", fmt.Errorf("absolute scope requires an absolute source path (starting with /)") } allowed, err := parseAllowedPaths(allowedPathsJSON) if err != nil { return "", fmt.Errorf("failed to parse allowed volume paths: %w", err) } if len(allowed) == 0 { return "", fmt.Errorf("absolute volume paths are disabled (no allowed paths configured in settings)") } for _, prefix := range allowed { prefixClean := filepath.Clean(prefix) if strings.HasPrefix(cleaned, prefixClean+string(filepath.Separator)) || cleaned == prefixClean { return cleaned, nil } } return "", fmt.Errorf("path %q is not under any allowed volume path", source) } // parseAllowedPaths parses a JSON array of path strings. func parseAllowedPaths(jsonStr string) ([]string, error) { if jsonStr == "" || jsonStr == "[]" { return nil, nil } var paths []string if err := json.Unmarshal([]byte(jsonStr), &paths); err != nil { return nil, err } return paths, nil } // ParseAllowedPaths is the exported version for use in API validation. func ParseAllowedPaths(jsonStr string) ([]string, error) { return parseAllowedPaths(jsonStr) }