package volume import ( "encoding/json" "fmt" "path/filepath" "regexp" "strings" "github.com/alexei/tinyforge/internal/store" ) // 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) } // ResolveWorkloadParams holds the parameters needed to resolve a // workload-volume's host path. Unlike ResolveParams it is keyed on the // workload identity (name + id) rather than the legacy project/stage // dual-key, so it survives the Workload-first cutover. type ResolveWorkloadParams struct { BasePath string WorkloadID string WorkloadName string ImageTag string // required for "instance" scope only AllowedVolumePaths string // JSON array of allowed absolute paths } // ResolveWorkloadPath returns the absolute host path for a WorkloadVolume. // Scope semantics map onto the workload-first model: // // - absolute — host bind, must lie under settings.AllowedVolumePaths. // - ephemeral — caller renders this as tmpfs; the function returns an // error because there is no host path. // - instance — per-tag isolation under /instance-/. // Useful for blue-green when each running instance needs its own dir. // - stage, project — both legacy names collapse to "shared across all // instances of this workload" under /. Two names // for one shape is intentional: it lets legacy data migrate without // a path rewrite. // - project_named — workload-scoped named volume under // /_named//. // - named — globally-scoped named volume under // _named//. // // The directory segment is `-`. The // short-id suffix prevents collisions when two workloads share a name // (the workloads table only enforces uniqueness on (kind, ref_id)). func ResolveWorkloadPath(vol store.WorkloadVolume, params ResolveWorkloadParams) (string, error) { scope := vol.Scope if scope == "" { return "", fmt.Errorf("workload volume: scope is required") } if scope == string(store.VolumeScopeEphemeral) { return "", fmt.Errorf("ephemeral volumes have no host path") } if scope == string(store.VolumeScopeAbsolute) { return resolveAbsolute(vol.Source, params.AllowedVolumePaths) } if params.BasePath == "" { return "", fmt.Errorf("workload volume: base path is required for scope %q", scope) } workloadDir, err := workloadPathSegment(params.WorkloadName, params.WorkloadID) if err != nil { return "", err } switch scope { case string(store.VolumeScopeInstance): if params.ImageTag == "" { return "", fmt.Errorf("instance scope requires image tag") } tag := sanitizePathSegment(params.ImageTag) if tag == "" { return "", fmt.Errorf("instance scope requires non-empty image tag") } return filepath.Join(params.BasePath, workloadDir, "instance-"+tag, vol.Source), nil case string(store.VolumeScopeStage), string(store.VolumeScopeProject): return filepath.Join(params.BasePath, workloadDir, vol.Source), nil case string(store.VolumeScopeProjectNamed): name := sanitizePathSegment(vol.Name) if name == "" { return "", fmt.Errorf("project_named scope requires name") } return filepath.Join(params.BasePath, workloadDir, "_named", name, vol.Source), nil case string(store.VolumeScopeNamed): name := sanitizePathSegment(vol.Name) if name == "" { return "", fmt.Errorf("named scope requires name") } return filepath.Join(params.BasePath, "_named", name, vol.Source), nil default: return "", fmt.Errorf("unknown volume scope %q", scope) } } // pathSegmentSanitizer collapses anything outside the [a-zA-Z0-9_.-] set // to a single dash. The character set matches Docker's permissive segment // rules; the additional Trim afterward keeps the segment from starting // or ending with a separator. var pathSegmentSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_.-]+`) func sanitizePathSegment(s string) string { s = strings.TrimSpace(s) if s == "" { return "" } return strings.Trim(pathSegmentSanitizer.ReplaceAllString(s, "-"), "-") } // workloadPathSegment builds the per-workload directory name. The // 8-char id-short suffix disambiguates same-named workloads — only // (kind, ref_id) is unique at the DB level, so names alone are unsafe. // Returns an error when both identity fields are empty, since the // resulting path would not be workload-scoped. func workloadPathSegment(name, id string) (string, error) { cleanName := sanitizePathSegment(name) idShort := id if len(idShort) > 8 { idShort = idShort[:8] } idShort = sanitizePathSegment(idShort) if cleanName == "" && idShort == "" { return "", fmt.Errorf("workload volume: workload id or name required") } if cleanName == "" { return idShort, nil } if idShort == "" { return cleanName, nil } return cleanName + "-" + idShort, nil }