diff --git a/protocol/job.go b/protocol/job.go index 275f929..c43a9fd 100644 --- a/protocol/job.go +++ b/protocol/job.go @@ -44,6 +44,7 @@ type RunJobProgressReport struct { type RunJobExecutionInput struct { WorkspaceScope string `json:"workspaceScope,omitempty"` Content string `json:"content,omitempty"` + FileTargetKey string `json:"fileTargetKey,omitempty"` ExpectedVersion int `json:"expectedVersion,omitempty"` ExpectedChecksum string `json:"expectedChecksum,omitempty"` MaxReadBytes int `json:"maxReadBytes,omitempty"` diff --git a/protocol/job_validation.go b/protocol/job_validation.go index 580ea04..e9b4fad 100644 --- a/protocol/job_validation.go +++ b/protocol/job_validation.go @@ -51,6 +51,9 @@ func ValidateRunJobAssignment(assignment RunJobAssignment) error { if assignment.ExecutionInput.MaxReadBytes < 0 || assignment.ExecutionInput.MaxReadBytes > maxRunExecutionContentBytes { return ValidationError("execution input maxReadBytes is out of bounds") } + if assignment.ExecutionInput.FileTargetKey != "" && !ValidLogicalFileKey(assignment.ExecutionInput.FileTargetKey) { + return ValidationError("execution input fileTargetKey is not allowed") + } if assignment.ExecutionInput.WorkspaceScope != "" && !ValidLogicalFileKey(assignment.ExecutionInput.WorkspaceScope) { return ValidationError("execution input workspaceScope is not allowed") } diff --git a/protocol/job_validation_test.go b/protocol/job_validation_test.go index c20a02a..205dc13 100644 --- a/protocol/job_validation_test.go +++ b/protocol/job_validation_test.go @@ -30,6 +30,15 @@ func TestValidateRunJobAssignmentScopedFilePayloads(t *testing.T) { if err := ValidateRunJobAssignment(assignment); err == nil || !strings.Contains(err.Error(), "inputRef") { t.Fatalf("expected raw credential ref rejection, got %v", err) } + assignment.InputRef = "input://server-config/server-1/server.properties/v1" + assignment.ExecutionInput.FileTargetKey = "SCUM/Saved/Config/WindowsServer/ServerSettings.ini" + if err := ValidateRunJobAssignment(assignment); err != nil { + t.Fatalf("expected valid declared relative file target: %v", err) + } + assignment.ExecutionInput.FileTargetKey = "../outside.ini" + if err := ValidateRunJobAssignment(assignment); err == nil || !strings.Contains(err.Error(), "fileTargetKey") { + t.Fatalf("expected declared file target escape rejection, got %v", err) + } } func TestValidateRunJobAssignmentScopedReadDoesNotRequireInputRef(t *testing.T) { diff --git a/runtime/execution_test.go b/runtime/execution_test.go index 4aa2d7e..64320dc 100644 --- a/runtime/execution_test.go +++ b/runtime/execution_test.go @@ -704,6 +704,47 @@ func TestScopedFileExecutorBoundsReads(t *testing.T) { } } +func TestDeclaredFileTargetKeyReadsAndWritesRelativeToDeploymentRoot(t *testing.T) { + workspaceRoot := t.TempDir() + serverRoot := t.TempDir() + filePath := filepath.Join(serverRoot, "SCUM", "Saved", "Config", "WindowsServer", "ServerSettings.ini") + if err := os.MkdirAll(filepath.Dir(filePath), 0o700); err != nil { + t.Fatalf("mkdir declared file fixture: %v", err) + } + if err := os.WriteFile(filePath, []byte("[General]\nscum.MaxPlayers=63\n"), 0o600); err != nil { + t.Fatalf("write declared file fixture: %v", err) + } + executor, err := NewFileExecutor(workspaceRoot) + if err != nil { + t.Fatalf("new file executor: %v", err) + } + assignment := executionAssignment(protocol.RunCapabilityFilesRead) + assignment.TargetKey = "scum-server-settings" + assignment.ExecutionInput.FileTargetKey = "SCUM/Saved/Config/WindowsServer/ServerSettings.ini" + assignment.ExecutionInput.Deployment = &protocol.ServerDeploymentExecution{SchemaVersion: "1", Mode: "guided-install", ServerRoot: serverRoot, Revision: 1} + read := executor.Execute(context.Background(), assignment) + if read.State != lifecycleResultStateSucceeded || read.ExecutionResult.Kind != "file.read" || read.ExecutionResult.Content != "[General]\nscum.MaxPlayers=63\n" { + t.Fatalf("expected declared target read, got %+v", read) + } + assignment.Capability = protocol.RunCapabilityFilesWrite + assignment.InputRef = "input://server-execution/settings-write" + assignment.ExecutionInput.Content = "[General]\nscum.MaxPlayers=80\n" + assignment.ExecutionInput.ExpectedVersion = read.ExecutionResult.Version + assignment.ExecutionInput.ExpectedChecksum = read.ExecutionResult.Checksum + write := executor.Execute(context.Background(), assignment) + if write.State != lifecycleResultStateSucceeded || write.ExecutionResult.Kind != "file.write" { + t.Fatalf("expected declared target write, got %+v", write) + } + body, err := os.ReadFile(filePath) + if err != nil || string(body) != assignment.ExecutionInput.Content { + t.Fatalf("declared target write used the wrong path, body=%q err=%v", body, err) + } + assignment.ExecutionInput.FileTargetKey = "../outside.ini" + if unsafe := executor.Execute(context.Background(), assignment); unsafe.State != lifecycleResultStateFailed { + t.Fatalf("expected unsafe declared target rejection, got %+v", unsafe) + } +} + func TestScopedFileExecutorListsDirectories(t *testing.T) { root := t.TempDir() executor, err := NewFileExecutor(root) diff --git a/runtime/file_execution.go b/runtime/file_execution.go index 99b8db5..dd9c19d 100644 --- a/runtime/file_execution.go +++ b/runtime/file_execution.go @@ -106,8 +106,8 @@ func (executor *FileExecutor) existingReadTargetForAssignment(assignment protoco if err != nil { return "", "", false, err } - targetKey := assignment.TargetKey - if deploymentRoot { + targetKey := fileTargetKeyForAssignment(assignment) + if deploymentRoot && assignment.ExecutionInput.FileTargetKey == "" { targetKey = deploymentTargetKey(targetKey) filePath, err := existingDeploymentTarget(scope, targetKey) return scope, filePath, deploymentRoot, err @@ -245,8 +245,8 @@ func (executor *FileExecutor) read(ctx context.Context, scope string, deployment if err := ctx.Err(); err != nil { return lifecycleExecutionFailure("file_cancelled", "file read cancelled", false) } - targetKey := assignment.TargetKey - if deploymentRoot { + targetKey := fileTargetKeyForAssignment(assignment) + if deploymentRoot && assignment.ExecutionInput.FileTargetKey == "" { targetKey = deploymentTargetKey(targetKey) } var filePath string @@ -292,8 +292,8 @@ func (executor *FileExecutor) write(ctx context.Context, scope string, deploymen if err := ctx.Err(); err != nil { return lifecycleExecutionFailure("file_cancelled", "file write cancelled", false) } - targetKey := assignment.TargetKey - if deploymentRoot { + targetKey := fileTargetKeyForAssignment(assignment) + if deploymentRoot && assignment.ExecutionInput.FileTargetKey == "" { targetKey = deploymentTargetKey(targetKey) } var filePath string @@ -365,6 +365,13 @@ func (executor *FileExecutor) write(ctx context.Context, scope string, deploymen return LifecycleExecutionResult{State: lifecycleResultStateSucceeded, Progress: protocol.RunJobProgressReport{Percent: 100, Message: "file write completed"}, Message: "file write completed", ExecutionResult: protocol.RunJobExecutionResult{Kind: "file.write", Version: next.Version, Checksum: checksum, SizeBytes: int64(len(content)), Summary: "atomic compare-and-swap file write"}} } +func fileTargetKeyForAssignment(assignment protocol.RunJobAssignment) string { + if targetKey := strings.TrimSpace(assignment.ExecutionInput.FileTargetKey); targetKey != "" { + return targetKey + } + return assignment.TargetKey +} + func deploymentTargetKey(value string) string { cleaned := path.Clean(strings.TrimPrefix(strings.ReplaceAll(strings.TrimSpace(value), "\\", "/"), "/")) if cleaned == "." || cleaned == "" {