package taskloop import ( "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "io/fs" "os" "path/filepath" "sort" "strconv" "strings" "time" "iop/packages/go/agentconfig" "iop/packages/go/agentguard" "iop/packages/go/agenttask" ) const ( planPrefix = "PLAN-" reviewPrefix = "CODE_REVIEW-" taskGroupPrefix = "m-" completeFileName = "complete.log" ) type MilestoneView struct { ID string Selected bool WorkUnits int CompletedWorkUnits int } // SelectionStore keeps the device-local explicit Milestone selection separate // from the repository-owned runtime defaults. type SelectionStore interface { SelectedMilestone(context.Context, string) (string, error) } // Workflow normalizes registered project artifacts for the shared manager. // It never mutates a project checkout. type Workflow struct { snapshot agentconfig.RuntimeSnapshot selections SelectionStore } var _ agenttask.WorkflowAdapter = (*Workflow)(nil) func NewWorkflow(snapshot agentconfig.RuntimeSnapshot, selections SelectionStore) (*Workflow, error) { if snapshot.Revision() == "" { return nil, errors.New("taskloop: runtime snapshot is required") } return &Workflow{snapshot: snapshot, selections: selections}, nil } func (workflow *Workflow) RegisteredProjects(ctx context.Context) ([]agenttask.ProjectID, error) { if err := ctx.Err(); err != nil { return nil, err } config := workflow.snapshot.Config() projects := make([]agenttask.ProjectID, 0, len(config.Projects)) for id, registration := range config.Projects { if registration.Enabled { projects = append(projects, agenttask.ProjectID(id)) } } sort.Slice(projects, func(left, right int) bool { return projects[left] < projects[right] }) return projects, nil } func (workflow *Workflow) Snapshot( ctx context.Context, projectID agenttask.ProjectID, ) (agenttask.ProjectWorkflowSnapshot, error) { if err := ctx.Err(); err != nil { return agenttask.ProjectWorkflowSnapshot{}, err } registration, ok := workflow.snapshot.Project(string(projectID)) if !ok || !registration.Enabled { return agenttask.ProjectWorkflowSnapshot{}, fmt.Errorf( "taskloop: project %q is not registered and enabled", projectID, ) } milestone := registration.SelectedMilestone if workflow.selections != nil { selected, err := workflow.selections.SelectedMilestone(ctx, string(projectID)) if err != nil { return agenttask.ProjectWorkflowSnapshot{}, err } if selected != "" { milestone = selected } } if strings.TrimSpace(milestone) == "" { return agenttask.ProjectWorkflowSnapshot{}, fmt.Errorf( "taskloop: project %q has no selected milestone", projectID, ) } root, err := canonicalDirectory(registration.Workspace) if err != nil { return agenttask.ProjectWorkflowSnapshot{}, fmt.Errorf( "taskloop: project %q workspace: %w", projectID, err, ) } units, evidence, err := scanWorkflow(root, milestone) if err != nil { return agenttask.ProjectWorkflowSnapshot{}, err } return agenttask.ProjectWorkflowSnapshot{ ProjectID: projectID, WorkspaceID: WorkspaceIdentity(root), Revision: agenttask.WorkflowRevision( digestStrings("workflow", string(projectID), milestone, evidence), ), Units: units, ObservedAt: time.Now().UTC(), }, nil } // WorkspaceIdentity is a stable, path-redacted identity for one canonical // registered project root. func WorkspaceIdentity(root string) agenttask.WorkspaceID { sum := sha256.Sum256([]byte(filepath.Clean(root))) return agenttask.WorkspaceID("workspace-" + hex.EncodeToString(sum[:])) } func parseMilestoneSlug(slug string) (string, error) { if slug == "" { return "", errors.New("taskloop: milestone slug cannot be empty") } for i := 0; i < len(slug); i++ { c := slug[i] if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') { continue } if c == '-' && i > 0 { continue } return "", fmt.Errorf("taskloop: milestone slug %q contains invalid character %q", slug, string(c)) } return slug, nil } func parseActiveGroupDir(dirName string) (string, error) { if !strings.HasPrefix(dirName, taskGroupPrefix) { return "", fmt.Errorf("taskloop: task group %q must have %q prefix", dirName, taskGroupPrefix) } rawSlug := strings.TrimPrefix(dirName, taskGroupPrefix) return parseMilestoneSlug(rawSlug) } func parseArchiveGroupDir(dirName string) (string, error) { if !strings.HasPrefix(dirName, taskGroupPrefix) { return "", fmt.Errorf("taskloop: task group %q must have %q prefix", dirName, taskGroupPrefix) } rest := strings.TrimPrefix(dirName, taskGroupPrefix) if idx := strings.IndexByte(rest, '_'); idx >= 0 { slugPart := rest[:idx] suffixPart := rest[idx+1:] if strings.IndexByte(suffixPart, '_') >= 0 || suffixPart == "" { return "", fmt.Errorf("taskloop: archive group directory %q has invalid collision suffix", dirName) } val, err := strconv.Atoi(suffixPart) if err != nil || val <= 0 || strconv.Itoa(val) != suffixPart { return "", fmt.Errorf("taskloop: archive group directory %q has invalid collision suffix %q", dirName, suffixPart) } return parseMilestoneSlug(slugPart) } return parseMilestoneSlug(rest) } func scanWorkflow(root, milestone string) ([]agenttask.WorkUnit, string, error) { slug := milestone if strings.HasPrefix(milestone, taskGroupPrefix) { slug = strings.TrimPrefix(milestone, taskGroupPrefix) } canonicalSlug, err := parseMilestoneSlug(slug) if err != nil { return nil, "", fmt.Errorf("taskloop: selected milestone %q: %w", milestone, err) } activeRoot := filepath.Join(root, "agent-task", taskGroupPrefix+canonicalSlug) entries, err := readOptionalTaskGroup(activeRoot) if err != nil { return nil, "", fmt.Errorf("taskloop: read active task group: %w", err) } units := make([]agenttask.WorkUnit, 0, len(entries)+1) evidence := make([]string, 0, len(entries)+1) seen := make(map[agenttask.WorkUnitID]string) single, singleEvidence, singleActive, err := readSinglePlanUnit(root, activeRoot, canonicalSlug) if err != nil { return nil, "", err } if singleActive { seen[single.ID] = taskGroupPrefix + canonicalSlug units = append(units, single) evidence = append(evidence, singleEvidence) } for _, entry := range entries { if !entry.IsDir() { continue } unit, unitEvidence, include, err := readActiveUnit(root, activeRoot, canonicalSlug, entry.Name()) if err != nil { return nil, "", err } if !include { continue } if singleActive { return nil, "", fmt.Errorf( "taskloop: task group %q mixes a single-plan root pair with split-plan task directory %q", taskGroupPrefix+canonicalSlug, entry.Name(), ) } if prior, duplicate := seen[unit.ID]; duplicate { return nil, "", fmt.Errorf( "taskloop: work identity %q is ambiguous between %q and %q", unit.ID, prior, entry.Name(), ) } seen[unit.ID] = entry.Name() units = append(units, unit) evidence = append(evidence, unitEvidence) } archived, archivedEvidence, err := readArchivedUnits(root, canonicalSlug, seen) if err != nil { return nil, "", err } units = append(units, archived...) evidence = append(evidence, archivedEvidence...) if len(units) == 0 { return nil, "", fmt.Errorf( "taskloop: selected milestone %q has no active or archived task artifacts", canonicalSlug, ) } sort.Slice(units, func(left, right int) bool { return units[left].ID < units[right].ID }) sort.Strings(evidence) return units, strings.Join(evidence, "\n"), nil } func discoverMilestoneCandidates(root string) ([]string, error) { candidates := make(map[string]struct{}) activeRoot := filepath.Join(root, "agent-task") activeEntries, err := readOptionalTaskGroup(activeRoot) if err != nil { return nil, fmt.Errorf("taskloop: read active task groups: %w", err) } for _, entry := range activeEntries { if !entry.IsDir() { continue } name := entry.Name() if name == "archive" { continue } if strings.HasPrefix(name, taskGroupPrefix) { slug, err := parseActiveGroupDir(name) if err != nil { return nil, err } candidates[slug] = struct{}{} } } archiveRoot := filepath.Join(root, "agent-task", "archive") years, err := os.ReadDir(archiveRoot) if err != nil && !errors.Is(err, fs.ErrNotExist) { return nil, fmt.Errorf("taskloop: read archive root: %w", err) } if err == nil { for _, year := range years { if !year.IsDir() { continue } yearPath := filepath.Join(archiveRoot, year.Name()) months, err := os.ReadDir(yearPath) if err != nil { return nil, fmt.Errorf("taskloop: read archive year %q: %w", year.Name(), err) } for _, month := range months { if !month.IsDir() { continue } monthPath := filepath.Join(yearPath, month.Name()) groupDirs, err := os.ReadDir(monthPath) if err != nil { return nil, fmt.Errorf("taskloop: read archive month %q/%q: %w", year.Name(), month.Name(), err) } for _, groupDir := range groupDirs { if !groupDir.IsDir() { continue } name := groupDir.Name() if strings.HasPrefix(name, taskGroupPrefix) { slug, err := parseArchiveGroupDir(name) if err != nil { return nil, err } candidates[slug] = struct{}{} } } } } } slugs := make([]string, 0, len(candidates)) for slug := range candidates { slugs = append(slugs, slug) } sort.Strings(slugs) return slugs, nil } func scanMilestones(root, selected string) ([]MilestoneView, error) { slugs, err := discoverMilestoneCandidates(root) if err != nil { return nil, err } views := make([]MilestoneView, 0, len(slugs)) for _, slug := range slugs { units, _, err := scanWorkflow(root, slug) if err != nil { return nil, err } completed := 0 for _, u := range units { if u.Completed { completed++ } } isSelected := slug == selected || taskGroupPrefix+slug == selected views = append(views, MilestoneView{ ID: slug, Selected: isSelected, WorkUnits: len(units), CompletedWorkUnits: completed, }) } if len(views) == 0 { return nil, errors.New("taskloop: no workflow-backed milestones") } return views, nil } func readOptionalTaskGroup(root string) ([]os.DirEntry, error) { entries, err := os.ReadDir(root) if errors.Is(err, fs.ErrNotExist) { return nil, nil } return entries, err } // InspectTaskGroup provides a configuration-free, read-only projection for an // operator dry-run. It deliberately uses the same workflow parser as Runtime // and never constructs a manager, provider, lease, or durable state store. func InspectTaskGroup(root, group string) ([]agenttask.WorkUnit, error) { if !strings.HasPrefix(group, taskGroupPrefix) { return nil, fmt.Errorf("taskloop: task group must be an exact m- prefixed identifier") } slugCandidate := strings.TrimPrefix(group, taskGroupPrefix) slug, err := parseMilestoneSlug(slugCandidate) if err != nil { return nil, fmt.Errorf("taskloop: task group must be an exact m- prefixed identifier: %w", err) } canonicalRoot, err := canonicalDirectory(root) if err != nil { return nil, err } units, _, err := scanWorkflow(canonicalRoot, slug) return units, err } func readArchivedUnits( root, milestone string, seen map[agenttask.WorkUnitID]string, ) ([]agenttask.WorkUnit, []string, error) { archiveRoot := filepath.Join(root, "agent-task", "archive") years, err := os.ReadDir(archiveRoot) if errors.Is(err, fs.ErrNotExist) { return nil, nil, nil } if err != nil { return nil, nil, fmt.Errorf("taskloop: read archive root: %w", err) } type archiveMatch struct { completionPath string workID agenttask.WorkUnitID aliases []string taskDirectory string } var matches []archiveMatch for _, year := range years { if !year.IsDir() { continue } yearPath := filepath.Join(archiveRoot, year.Name()) months, err := os.ReadDir(yearPath) if err != nil { return nil, nil, fmt.Errorf("taskloop: read archive year %q: %w", year.Name(), err) } for _, month := range months { if !month.IsDir() { continue } monthPath := filepath.Join(yearPath, month.Name()) groupDirs, err := os.ReadDir(monthPath) if err != nil { return nil, nil, fmt.Errorf("taskloop: read archive month %q/%q: %w", year.Name(), month.Name(), err) } for _, groupDir := range groupDirs { if !groupDir.IsDir() { continue } groupName := groupDir.Name() if !strings.HasPrefix(groupName, taskGroupPrefix) { continue } slug, err := parseArchiveGroupDir(groupName) if err != nil { return nil, nil, fmt.Errorf("taskloop: archived task group %q: %w", groupName, err) } if slug != milestone { continue } groupPath := filepath.Join(monthPath, groupName) directCompletion := filepath.Join(groupPath, completeFileName) if info, statErr := os.Stat(directCompletion); statErr == nil && info.Mode().IsRegular() { matches = append(matches, archiveMatch{ completionPath: directCompletion, workID: singlePlanWorkUnitID(milestone), aliases: []string{taskGroupPrefix + milestone, milestone}, taskDirectory: taskGroupPrefix + milestone, }) } else if statErr != nil && !errors.Is(statErr, fs.ErrNotExist) { return nil, nil, fmt.Errorf("taskloop: inspect archive completion %q: %w", directCompletion, statErr) } subEntries, err := os.ReadDir(groupPath) if err != nil { return nil, nil, fmt.Errorf("taskloop: read archive task group %q: %w", groupName, err) } for _, sub := range subEntries { if !sub.IsDir() { continue } taskDirName := sub.Name() subCompletion := filepath.Join(groupPath, taskDirName, completeFileName) info, statErr := os.Stat(subCompletion) if statErr == nil && info.Mode().IsRegular() { id, _, aliases, parseErr := parseTaskDirectory(taskDirName) if parseErr != nil { return nil, nil, fmt.Errorf("taskloop: archived task %q: %w", taskDirName, parseErr) } matches = append(matches, archiveMatch{ completionPath: subCompletion, workID: agenttask.WorkUnitID(id), aliases: aliases, taskDirectory: taskDirName, }) } else if statErr != nil && !errors.Is(statErr, fs.ErrNotExist) { return nil, nil, fmt.Errorf("taskloop: inspect archive completion %q: %w", subCompletion, statErr) } } } } } sort.Slice(matches, func(i, j int) bool { return matches[i].completionPath < matches[j].completionPath }) var units []agenttask.WorkUnit var evidence []string for _, m := range matches { unit, unitEvidence, include, err := readArchivedUnit( root, milestone, m.completionPath, m.workID, m.aliases, m.taskDirectory, seen, ) if err != nil { return nil, nil, err } if include { units = append(units, unit) evidence = append(evidence, unitEvidence) } } return units, evidence, nil } func readArchivedUnit( root, milestone, completion string, workID agenttask.WorkUnitID, aliases []string, taskDirectory string, seen map[agenttask.WorkUnitID]string, ) (agenttask.WorkUnit, string, bool, error) { if _, exists := seen[workID]; exists { return agenttask.WorkUnit{}, "", false, nil } content, err := os.ReadFile(completion) if err != nil { return agenttask.WorkUnit{}, "", false, err } seen[workID] = taskDirectory relative, err := filepath.Rel(root, completion) if err != nil { return agenttask.WorkUnit{}, "", false, err } return agenttask.WorkUnit{ ID: workID, MilestoneID: agenttask.MilestoneID(milestone), Aliases: uniqueStrings(aliases), WriteSetKind: agenttask.WriteSetUnknown, IsolationMode: agentguard.IsolationModeOverlay, Completed: true, Metadata: map[string]string{"task_directory": taskDirectory}, }, digestBytes(filepath.ToSlash(relative), content), true, nil } func readSinglePlanUnit( root, activeRoot, milestone string, ) (agenttask.WorkUnit, string, bool, error) { taskGroup := taskGroupPrefix + milestone plan, review, include, err := matchingActivePair(activeRoot, taskGroup) if err != nil || !include { return agenttask.WorkUnit{}, "", include, err } return normalizeActiveUnit( root, activeRoot, milestone, taskGroup, plan, review, string(singlePlanWorkUnitID(milestone)), nil, []string{taskGroup, milestone}, ) } func readActiveUnit( root, activeRoot, milestone, directory string, ) (agenttask.WorkUnit, string, bool, error) { taskRoot := filepath.Join(activeRoot, directory) plan, review, include, err := matchingActivePair(taskRoot, directory) if err != nil || !include { return agenttask.WorkUnit{}, "", include, err } id, predecessors, aliases, err := parseTaskDirectory(directory) if err != nil { return agenttask.WorkUnit{}, "", false, err } return normalizeActiveUnit( root, taskRoot, milestone, directory, plan, review, id, predecessors, aliases, ) } func matchingActivePair(root, task string) (string, string, bool, error) { plans, err := matchingMarkdown(root, planPrefix) if errors.Is(err, fs.ErrNotExist) { return "", "", false, nil } if err != nil { return "", "", false, err } reviews, err := matchingMarkdown(root, reviewPrefix) if err != nil { return "", "", false, err } if len(plans) == 0 && len(reviews) == 0 { return "", "", false, nil } if len(plans) != 1 { return "", "", false, fmt.Errorf( "taskloop: task %q must contain exactly one active PLAN, found %d", task, len(plans), ) } if len(reviews) != 1 { return "", "", false, fmt.Errorf( "taskloop: task %q must contain exactly one active CODE_REVIEW, found %d", task, len(reviews), ) } return plans[0], reviews[0], true, nil } func normalizeActiveUnit( root, taskRoot, milestone, taskDirectory, plan, review, id string, predecessors, aliases []string, ) (agenttask.WorkUnit, string, bool, error) { planBytes, err := os.ReadFile(plan) if err != nil { return agenttask.WorkUnit{}, "", false, err } writeSet, err := parseModifiedFilesSummary(root, planBytes) if err != nil { return agenttask.WorkUnit{}, "", false, fmt.Errorf( "taskloop: task %q PLAN: %w", taskDirectory, err, ) } planRelative, err := filepath.Rel(root, plan) if err != nil { return agenttask.WorkUnit{}, "", false, err } reviewRelative, err := filepath.Rel(root, review) if err != nil { return agenttask.WorkUnit{}, "", false, err } completed := false if info, statErr := os.Stat(filepath.Join(taskRoot, completeFileName)); statErr == nil { completed = info.Mode().IsRegular() } else if !errors.Is(statErr, fs.ErrNotExist) { return agenttask.WorkUnit{}, "", false, statErr } dependencies := make([]agenttask.DependencyRef, len(predecessors)) for index, predecessor := range predecessors { dependencies[index] = agenttask.DependencyRef{Ref: predecessor} } kind := agenttask.WriteSetUnknown if len(writeSet) > 0 { kind = agenttask.WriteSetDisjoint } unit := agenttask.WorkUnit{ ID: agenttask.WorkUnitID(id), MilestoneID: agenttask.MilestoneID(milestone), Aliases: aliases, ExplicitPredecessors: dependencies, WriteSetKind: kind, DeclaredWriteSet: writeSet, IsolationMode: agentguard.IsolationModeOverlay, Completed: completed, Metadata: map[string]string{ "task_directory": taskDirectory, "plan_path": filepath.ToSlash(planRelative), "review_path": filepath.ToSlash(reviewRelative), }, } return unit, digestBytes(filepath.ToSlash(planRelative), planBytes), true, nil } func singlePlanWorkUnitID(milestone string) agenttask.WorkUnitID { return agenttask.WorkUnitID(taskGroupPrefix + milestone) } func matchingMarkdown(root, prefix string) ([]string, error) { entries, err := os.ReadDir(root) if err != nil { return nil, err } var matches []string for _, entry := range entries { if entry.IsDir() || !strings.HasPrefix(entry.Name(), prefix) || !strings.HasSuffix(entry.Name(), ".md") { continue } matches = append(matches, filepath.Join(root, entry.Name())) } sort.Strings(matches) return matches, nil } func parseTaskDirectory(directory string) (string, []string, []string, error) { head, slug, found := strings.Cut(directory, "_") if !found || head == "" || slug == "" { return "", nil, nil, fmt.Errorf( "taskloop: task directory %q does not have _ form", directory, ) } id := head var predecessors []string if primary, dependencyText, hasDependencies := strings.Cut(head, "+"); hasDependencies { id = primary if id == "" || dependencyText == "" { return "", nil, nil, fmt.Errorf( "taskloop: task directory %q has malformed explicit predecessors", directory, ) } for _, predecessor := range strings.Split(dependencyText, ",") { if predecessor == "" { return "", nil, nil, fmt.Errorf( "taskloop: task directory %q has an empty predecessor", directory, ) } predecessors = append(predecessors, predecessor) } } if strings.TrimSpace(id) != id || strings.ContainsAny(id, "/\\\x00\r\n") { return "", nil, nil, fmt.Errorf("taskloop: task directory %q has invalid identity", directory) } aliases := []string{directory, head, slug} if id != head { aliases = append(aliases, id) } return id, predecessors, uniqueStrings(aliases), nil } // ValidatePlanWriteSet validates a candidate PLAN without constructing a // runtime, provider, lease, or durable store. The candidate may be outside // workspace, but every declared write target must be a contained literal file. func ValidatePlanWriteSet(workspace, planPath string) ([]string, error) { root, err := canonicalDirectory(workspace) if err != nil { return nil, fmt.Errorf("taskloop: workspace: %w", err) } if strings.TrimSpace(planPath) == "" { return nil, errors.New("taskloop: candidate PLAN path is required") } plan, err := os.ReadFile(planPath) if err != nil { return nil, fmt.Errorf("taskloop: read candidate PLAN: %w", err) } return parseModifiedFilesSummary(root, plan) } func parseModifiedFilesSummary(workspace string, plan []byte) ([]string, error) { lines := strings.Split(string(plan), "\n") heading := -1 for index, line := range lines { if isModifiedFilesSummaryHeading(line) { if heading >= 0 { return nil, errors.New("Modified Files Summary appears more than once") } heading = index } } if heading < 0 { return nil, errors.New("Modified Files Summary is missing") } var files []string seen := make(map[string]struct{}) for _, line := range lines[heading+1:] { trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "## ") { break } if !strings.HasPrefix(trimmed, "|") { continue } columns := strings.Split(strings.Trim(trimmed, "|"), "|") if len(columns) == 0 { continue } cell := strings.TrimSpace(columns[0]) if isModifiedFilesHeader(cell) || strings.Trim(cell, "-: ") == "" { continue } claims, err := backtickClaims(cell) if err != nil { return nil, err } for _, claim := range claims { path, err := canonicalWriteClaim(workspace, claim) if err != nil { return nil, err } if _, duplicate := seen[path]; duplicate { return nil, fmt.Errorf("file path %q is duplicated", path) } seen[path] = struct{}{} files = append(files, path) } } if len(files) == 0 { return nil, errors.New("Modified Files Summary has no file rows") } sort.Strings(files) return files, nil } func isModifiedFilesSummaryHeading(line string) bool { trimmed := strings.TrimSpace(line) if !strings.HasPrefix(trimmed, "##") || strings.HasPrefix(trimmed, "###") { return false } return strings.TrimSpace(strings.TrimPrefix(trimmed, "##")) == "Modified Files Summary" || strings.TrimSpace(strings.TrimPrefix(trimmed, "##")) == "수정 파일 요약" } func isModifiedFilesHeader(cell string) bool { switch strings.ToLower(strings.TrimSpace(cell)) { case "file", "files", "path", "paths", "파일", "경로": return true default: return false } } func backtickClaims(cell string) ([]string, error) { if strings.Contains(cell, "``") { return nil, errors.New("empty file path claim is not allowed") } var claims []string for rest := cell; ; { start := strings.IndexByte(rest, '`') if start < 0 { break } rest = rest[start+1:] end := strings.IndexByte(rest, '`') if end < 0 { return nil, fmt.Errorf("file cell %q has an unmatched backtick", cell) } claim := strings.TrimSpace(rest[:end]) if claim == "" { return nil, errors.New("empty file path claim is not allowed") } claims = append(claims, claim) rest = rest[end+1:] } if len(claims) == 0 { return nil, fmt.Errorf("file cell %q must contain a backtick path", cell) } return claims, nil } func canonicalWriteClaim(workspace, value string) (string, error) { claim := strings.TrimSpace(value) claim = trimOptionalLineSuffix(claim) if claim == "" { return "", errors.New("empty file path claim is not allowed") } lower := strings.ToLower(claim) if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") { return "", fmt.Errorf("URL claim %q is not allowed", claim) } if strings.Contains(claim, "\\") || strings.ContainsAny(claim, "*?[]{}<>") || strings.Contains(claim, "...") || containsPlaceholderPathToken(lower) { return "", fmt.Errorf("file path %q is not a contained literal file", claim) } if strings.HasSuffix(claim, "/") { return "", fmt.Errorf("directory claim %q is not allowed", claim) } candidate := filepath.FromSlash(claim) if !filepath.IsAbs(candidate) { candidate = filepath.Join(workspace, candidate) } candidate = filepath.Clean(candidate) resolved, err := canonicalizeCandidate(candidate) if err != nil { return "", fmt.Errorf("file path %q cannot be canonicalized: %w", claim, err) } relative, err := filepath.Rel(workspace, resolved) if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { if relative == "." { return "", errors.New("workspace root claim is not allowed") } return "", fmt.Errorf("file path %q is outside the workspace", claim) } if info, err := os.Stat(resolved); err == nil && info.IsDir() { return "", fmt.Errorf("directory claim %q is not allowed", claim) } else if err != nil && !errors.Is(err, fs.ErrNotExist) { return "", fmt.Errorf("file path %q cannot be inspected: %w", claim, err) } return filepath.ToSlash(relative), nil } func trimOptionalLineSuffix(value string) string { last := strings.LastIndexByte(value, ':') if last < 0 || last == len(value)-1 || !allDigits(value[last+1:]) { return value } value = value[:last] last = strings.LastIndexByte(value, ':') if last >= 0 && last < len(value)-1 && allDigits(value[last+1:]) { return value[:last] } return value } func allDigits(value string) bool { return value != "" && strings.Trim(value, "0123456789") == "" } func containsPlaceholderPathToken(value string) bool { for _, token := range strings.FieldsFunc(value, func(r rune) bool { return r == '/' || r == '_' || r == '.' || r == '-' }) { if token == "tbd" || token == "todo" || token == "placeholder" { return true } } return false } func canonicalizeCandidate(candidate string) (string, error) { return canonicalizeCandidateSeen(filepath.Clean(candidate), make(map[string]struct{}), 0) } const maxCandidateSymlinkResolutions = 255 // canonicalizeCandidateSeen resolves existing path components one at a time so // a dangling leaf symlink cannot be mistaken for a contained new file. A // genuinely new suffix is retained after its nearest physical parent is // resolved, which preserves normal PLAN entries for files that do not exist // yet. func canonicalizeCandidateSeen(candidate string, seen map[string]struct{}, depth int) (string, error) { if depth > maxCandidateSymlinkResolutions { return "", errors.New("too many symlink resolutions") } if resolved, err := filepath.EvalSymlinks(candidate); err == nil { return filepath.Clean(resolved), nil } else if !errors.Is(err, fs.ErrNotExist) { return "", err } info, err := os.Lstat(candidate) if err == nil && info.Mode()&os.ModeSymlink != 0 { if _, duplicate := seen[candidate]; duplicate { return "", errors.New("symlink loop") } seen[candidate] = struct{}{} target, err := os.Readlink(candidate) if err != nil { return "", err } if !filepath.IsAbs(target) { target = filepath.Join(filepath.Dir(candidate), target) } return canonicalizeCandidateSeen(filepath.Clean(target), seen, depth+1) } if err != nil && !errors.Is(err, fs.ErrNotExist) { return "", err } parent, leaf := filepath.Dir(candidate), filepath.Base(candidate) // Walking ordinary missing path components does not resolve a symlink. Keep // the resolution budget for actual links so a valid, deeply nested new file // is not rejected merely because its parent directories do not exist yet. resolvedParent, err := canonicalizeCandidateSeen(parent, seen, depth) if err != nil { return "", err } return filepath.Join(resolvedParent, leaf), nil } func canonicalDirectory(path string) (string, error) { if strings.TrimSpace(path) == "" || !filepath.IsAbs(path) || filepath.Clean(path) != path { return "", errors.New("path must be absolute and clean") } canonical, err := filepath.EvalSymlinks(path) if err != nil { return "", err } info, err := os.Stat(canonical) if err != nil { return "", err } if !info.IsDir() { return "", errors.New("path is not a directory") } return filepath.Clean(canonical), nil } func digestBytes(label string, data []byte) string { sum := sha256.Sum256(append(append([]byte(label), 0), data...)) return "sha256:" + hex.EncodeToString(sum[:]) } func digestStrings(parts ...string) string { hash := sha256.New() for _, part := range parts { _, _ = fmt.Fprintf(hash, "%d:", len(part)) _, _ = hash.Write([]byte(part)) } return "sha256:" + hex.EncodeToString(hash.Sum(nil)) } func uniqueStrings(values []string) []string { seen := make(map[string]struct{}, len(values)) out := make([]string, 0, len(values)) for _, value := range values { if _, ok := seen[value]; ok { continue } seen[value] = struct{}{} out = append(out, value) } return out }