804 lines
23 KiB
Go
804 lines
23 KiB
Go
package taskloop
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"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"
|
|
)
|
|
|
|
// 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 scanWorkflow(root, milestone string) ([]agenttask.WorkUnit, string, error) {
|
|
activeRoot := filepath.Join(root, "agent-task", taskGroupPrefix+milestone)
|
|
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, milestone)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if singleActive {
|
|
seen[single.ID] = taskGroupPrefix + milestone
|
|
units = append(units, single)
|
|
evidence = append(evidence, singleEvidence)
|
|
}
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
unit, unitEvidence, include, err := readActiveUnit(root, activeRoot, milestone, 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+milestone,
|
|
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, milestone, 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",
|
|
milestone,
|
|
)
|
|
}
|
|
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 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) || len(group) == len(taskGroupPrefix) || strings.ContainsAny(group, "/\\\x00\r\n") {
|
|
return nil, fmt.Errorf("taskloop: task group must be an exact m- prefixed identifier")
|
|
}
|
|
canonicalRoot, err := canonicalDirectory(root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
units, _, err := scanWorkflow(canonicalRoot, strings.TrimPrefix(group, taskGroupPrefix))
|
|
return units, err
|
|
}
|
|
|
|
func readArchivedUnits(
|
|
root, milestone string,
|
|
seen map[agenttask.WorkUnitID]string,
|
|
) ([]agenttask.WorkUnit, []string, error) {
|
|
directPattern := filepath.Join(
|
|
root,
|
|
"agent-task",
|
|
"archive",
|
|
"*",
|
|
"*",
|
|
taskGroupPrefix+milestone,
|
|
completeFileName,
|
|
)
|
|
directCompletions, err := filepath.Glob(directPattern)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
pattern := filepath.Join(
|
|
root,
|
|
"agent-task",
|
|
"archive",
|
|
"*",
|
|
"*",
|
|
taskGroupPrefix+milestone,
|
|
"*",
|
|
completeFileName,
|
|
)
|
|
completions, err := filepath.Glob(pattern)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
sort.Strings(directCompletions)
|
|
sort.Strings(completions)
|
|
var units []agenttask.WorkUnit
|
|
var evidence []string
|
|
directID := singlePlanWorkUnitID(milestone)
|
|
for _, completion := range directCompletions {
|
|
unit, unitEvidence, include, err := readArchivedUnit(
|
|
root,
|
|
milestone,
|
|
completion,
|
|
directID,
|
|
[]string{taskGroupPrefix + milestone, milestone},
|
|
taskGroupPrefix+milestone,
|
|
seen,
|
|
)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if include {
|
|
units = append(units, unit)
|
|
evidence = append(evidence, unitEvidence)
|
|
}
|
|
}
|
|
for _, completion := range completions {
|
|
taskDirectory := filepath.Base(filepath.Dir(completion))
|
|
id, _, aliases, err := parseTaskDirectory(taskDirectory)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("taskloop: archived task %q: %w", taskDirectory, err)
|
|
}
|
|
unit, unitEvidence, include, err := readArchivedUnit(
|
|
root,
|
|
milestone,
|
|
completion,
|
|
agenttask.WorkUnitID(id),
|
|
aliases,
|
|
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 <id>_<slug> 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
|
|
}
|