diff --git a/runtime/execution_test.go b/runtime/execution_test.go index fd6b51c..4199606 100644 --- a/runtime/execution_test.go +++ b/runtime/execution_test.go @@ -646,6 +646,36 @@ func TestScopedFileExecutorListsDirectories(t *testing.T) { } } +func TestDeploymentFileExecutorUsesServerRoot(t *testing.T) { + workspaceRoot := t.TempDir() + serverRoot := t.TempDir() + if err := os.WriteFile(filepath.Join(workspaceRoot, ".platform"), []byte("workspace"), 0o600); err != nil { + t.Fatalf("write workspace marker: %v", err) + } + if err := os.WriteFile(filepath.Join(serverRoot, "SCUMServer.exe"), []byte("server"), 0o600); err != nil { + t.Fatalf("write server marker: %v", err) + } + executor, err := NewFileExecutor(workspaceRoot) + if err != nil { + t.Fatalf("new file executor: %v", err) + } + assignment := executionAssignment(protocol.RunCapabilityFilesList) + assignment.TargetKey = "server-root" + assignment.ExecutionInput.Inputs = map[string]string{"path": "", "recursive": "false", "query": ""} + assignment.ExecutionInput.Deployment = &protocol.ServerDeploymentExecution{SchemaVersion: "1", Mode: "guided-install", ServerRoot: serverRoot, Revision: 1} + result := executor.Execute(context.Background(), assignment) + if result.State != lifecycleResultStateSucceeded || result.ExecutionResult.Kind != "file.list" { + t.Fatalf("expected deployment root listing, got %+v", result) + } + var envelope fileListEnvelope + if err := json.Unmarshal([]byte(result.ExecutionResult.Content), &envelope); err != nil { + t.Fatalf("decode deployment listing: %v", err) + } + if len(envelope.Entries) != 1 || envelope.Entries[0].Name != "SCUMServer.exe" { + t.Fatalf("expected only deployment root entry, got %+v", envelope.Entries) + } +} + func TestScopedFileExecutorCancellationLeavesTargetUnchanged(t *testing.T) { root := t.TempDir() executor, err := NewFileExecutor(root) diff --git a/runtime/file_execution.go b/runtime/file_execution.go index 650ac1c..c2824c0 100644 --- a/runtime/file_execution.go +++ b/runtime/file_execution.go @@ -58,22 +58,22 @@ func (executor *FileExecutor) Execute(ctx context.Context, assignment protocol.R if assignment.ServerInstanceID == "" || assignment.ExecutionInput.WorkspaceScope == "" { return lifecycleExecutionFailure("file_workspace_invalid", "file workspace scope is required", false) } - scope, err := executor.resolver.Scope(assignment.ServerInstanceID, assignment.ExecutionInput.WorkspaceScope) + scope, deploymentRoot, err := executor.scopeForAssignment(assignment) if err != nil { return lifecycleExecutionFailure("file_workspace_invalid", err.Error(), false) } if assignment.Capability == protocol.RunCapabilityFilesRead { - result := executor.read(ctx, scope, assignment) + result := executor.read(ctx, scope, deploymentRoot, assignment) if result.ExecutionResult.Kind == "file" { result.ExecutionResult.Kind = "file.read" } return result } if assignment.Capability == protocol.RunCapabilityFilesList { - return executor.list(ctx, scope, assignment) + return executor.list(ctx, scope, deploymentRoot, assignment) } if assignment.Capability == protocol.RunCapabilityConfigWrite || assignment.Capability == protocol.RunCapabilityFilesWrite { - result := executor.write(ctx, scope, assignment) + result := executor.write(ctx, scope, deploymentRoot, assignment) if result.ExecutionResult.Kind == "file" { result.ExecutionResult.Kind = "file.write" } @@ -82,6 +82,25 @@ func (executor *FileExecutor) Execute(ctx context.Context, assignment protocol.R return lifecycleExecutionFailure("unsupported_file_capability", "unsupported file capability", false) } +func (executor *FileExecutor) scopeForAssignment(assignment protocol.RunJobAssignment) (string, bool, error) { + if deployment := assignment.ExecutionInput.Deployment; deployment != nil && strings.TrimSpace(deployment.ServerRoot) != "" { + root := filepath.Clean(strings.TrimSpace(deployment.ServerRoot)) + if !filepath.IsAbs(root) { + return "", false, fmt.Errorf("deployment server root must be absolute") + } + info, err := os.Lstat(root) + if err != nil { + return "", false, fmt.Errorf("deployment server root is unavailable: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return "", false, fmt.Errorf("deployment server root must be a real directory") + } + return root, true, nil + } + scope, err := executor.resolver.Scope(assignment.ServerInstanceID, assignment.ExecutionInput.WorkspaceScope) + return scope, false, err +} + type fileListEntry struct { Name string `json:"name"` Kind string `json:"kind"` @@ -97,7 +116,7 @@ type fileListEnvelope struct { Entries []fileListEntry `json:"entries"` } -func (executor *FileExecutor) list(ctx context.Context, scope string, assignment protocol.RunJobAssignment) LifecycleExecutionResult { +func (executor *FileExecutor) list(ctx context.Context, scope string, deploymentRoot bool, assignment protocol.RunJobAssignment) LifecycleExecutionResult { if err := ctx.Err(); err != nil { return lifecycleExecutionFailure("file_cancelled", "file list cancelled", false) } @@ -112,7 +131,11 @@ func (executor *FileExecutor) list(ctx context.Context, scope string, assignment directory := scope var err error if relativePath != "." { - directory, err = executor.resolver.ExistingDirectory(scope, relativePath) + if deploymentRoot { + directory, err = existingDeploymentDirectory(scope, relativePath) + } else { + directory, err = executor.resolver.ExistingDirectory(scope, relativePath) + } if err != nil { return lifecycleExecutionFailure("file_list_failed", err.Error(), false) } @@ -202,11 +225,21 @@ func (executor *FileExecutor) list(ctx context.Context, scope string, assignment return LifecycleExecutionResult{State: lifecycleResultStateSucceeded, Progress: protocol.RunJobProgressReport{Percent: 100, Message: "file list completed"}, Message: "file list completed", ExecutionResult: protocol.RunJobExecutionResult{Kind: "file.list", SizeBytes: int64(len(body)), Content: string(body), Summary: "bounded logical file listing"}} } -func (executor *FileExecutor) read(ctx context.Context, scope string, assignment protocol.RunJobAssignment) LifecycleExecutionResult { +func (executor *FileExecutor) read(ctx context.Context, scope string, deploymentRoot bool, assignment protocol.RunJobAssignment) LifecycleExecutionResult { if err := ctx.Err(); err != nil { return lifecycleExecutionFailure("file_cancelled", "file read cancelled", false) } - path, err := executor.resolver.ExistingTarget(scope, assignment.TargetKey) + targetKey := assignment.TargetKey + if deploymentRoot { + targetKey = deploymentTargetKey(targetKey) + } + var filePath string + var err error + if deploymentRoot { + filePath, err = existingDeploymentTarget(scope, targetKey) + } else { + filePath, err = executor.resolver.ExistingTarget(scope, targetKey) + } if err != nil { return lifecycleExecutionFailure("file_read_failed", err.Error(), false) } @@ -214,14 +247,14 @@ func (executor *FileExecutor) read(ctx context.Context, scope string, assignment if limit <= 0 || limit > maxExecutionContentBytes { limit = maxExecutionContentBytes } - info, err := os.Stat(path) + info, err := os.Stat(filePath) if err != nil { return lifecycleExecutionFailure("file_read_failed", err.Error(), false) } if info.Size() > int64(limit) { return lifecycleExecutionFailure("file_read_too_large", "file exceeds approved read limit", false) } - file, err := os.Open(path) + file, err := os.Open(filePath) if err != nil { return lifecycleExecutionFailure("file_read_failed", err.Error(), false) } @@ -235,7 +268,7 @@ func (executor *FileExecutor) read(ctx context.Context, scope string, assignment return LifecycleExecutionResult{State: lifecycleResultStateSucceeded, Progress: protocol.RunJobProgressReport{Percent: 100, Message: "file read completed"}, Message: "file read completed", ExecutionResult: protocol.RunJobExecutionResult{Kind: "file.read", Version: metadata.Version, Checksum: checksum, SizeBytes: int64(len(content)), Content: string(content), Summary: "bounded regular-file read"}} } -func (executor *FileExecutor) write(ctx context.Context, scope string, assignment protocol.RunJobAssignment) LifecycleExecutionResult { +func (executor *FileExecutor) write(ctx context.Context, scope string, deploymentRoot bool, assignment protocol.RunJobAssignment) LifecycleExecutionResult { content := []byte(assignment.ExecutionInput.Content) if len(content) > maxExecutionContentBytes { return lifecycleExecutionFailure("file_write_too_large", "approved content is too large", false) @@ -243,13 +276,24 @@ func (executor *FileExecutor) write(ctx context.Context, scope string, assignmen if err := ctx.Err(); err != nil { return lifecycleExecutionFailure("file_cancelled", "file write cancelled", false) } - path, parent, err := executor.resolver.WritableTarget(scope, assignment.TargetKey) + targetKey := assignment.TargetKey + if deploymentRoot { + targetKey = deploymentTargetKey(targetKey) + } + var filePath string + var parent string + var err error + if deploymentRoot { + filePath, parent, err = writableDeploymentTarget(scope, targetKey) + } else { + filePath, parent, err = executor.resolver.WritableTarget(scope, targetKey) + } if err != nil { return lifecycleExecutionFailure("file_target_invalid", err.Error(), false) } executor.mu.Lock() defer executor.mu.Unlock() - current, err := executor.currentMetadataLocked(scope, assignment.TargetKey, path) + current, err := executor.currentMetadataLocked(scope, assignment.TargetKey, filePath) if err != nil { return lifecycleExecutionFailure("file_metadata_failed", err.Error(), false) } @@ -293,7 +337,7 @@ func (executor *FileExecutor) write(ctx context.Context, scope string, assignmen if err := ctx.Err(); err != nil { return lifecycleExecutionFailure("file_cancelled", "file write cancelled", false) } - if err := os.Rename(temporaryName, path); err != nil { + if err := os.Rename(temporaryName, filePath); err != nil { return lifecycleExecutionFailure("file_write_failed", err.Error(), false) } checksum := bytesChecksum(content) @@ -305,6 +349,101 @@ func (executor *FileExecutor) write(ctx context.Context, scope string, assignmen 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 deploymentTargetKey(value string) string { + cleaned := path.Clean(strings.TrimPrefix(strings.ReplaceAll(strings.TrimSpace(value), "\\", "/"), "/")) + if cleaned == "." || cleaned == "" { + return "" + } + if separator := strings.IndexByte(cleaned, '/'); separator >= 0 { + return cleaned[separator+1:] + } + return cleaned +} + +func existingDeploymentDirectory(root string, key string) (string, error) { + target, err := deploymentPath(root, key, false) + if err != nil { + return "", err + } + info, err := os.Lstat(target) + if err != nil { + return "", err + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return "", fmt.Errorf("logical directory is not a real directory") + } + return target, nil +} + +func existingDeploymentTarget(root string, key string) (string, error) { + target, err := deploymentPath(root, key, false) + if err != nil { + return "", err + } + info, err := os.Lstat(target) + if err != nil { + return "", err + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return "", fmt.Errorf("target must be a regular file") + } + return target, nil +} + +func writableDeploymentTarget(root string, key string) (string, string, error) { + if key == "" || key == "." || key == "actions" || key == "state" || strings.HasPrefix(key, "actions/") || strings.HasPrefix(key, "state/") { + return "", "", fmt.Errorf("target is reserved or invalid") + } + parts := strings.Split(filepath.ToSlash(key), "/") + parent := root + for _, part := range parts[:len(parts)-1] { + if part == "" || part == "." || part == ".." { + return "", "", fmt.Errorf("logical key contains unsafe component") + } + parent = filepath.Join(parent, part) + if err := ensureDirectory(parent); err != nil { + return "", "", err + } + } + target, err := deploymentPath(root, key, true) + if err != nil { + return "", "", err + } + return target, filepath.Dir(target), nil +} + +func deploymentPath(root string, key string, allowMissingFinal bool) (string, error) { + if strings.TrimSpace(root) == "" { + return "", fmt.Errorf("deployment server root is empty") + } + cleaned := path.Clean(strings.TrimPrefix(strings.ReplaceAll(strings.TrimSpace(key), "\\", "/"), "/")) + if cleaned == "." || cleaned == "" || strings.HasPrefix(cleaned, "../") || cleaned == ".." { + return "", fmt.Errorf("logical key is unsafe") + } + current := root + parts := strings.Split(cleaned, "/") + for index, part := range parts { + if part == "" || part == "." || part == ".." { + return "", fmt.Errorf("logical key contains unsafe component") + } + current = filepath.Join(current, part) + info, err := os.Lstat(current) + if err != nil { + if allowMissingFinal && index == len(parts)-1 && os.IsNotExist(err) { + return current, nil + } + return "", err + } + if info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("logical key contains a symlink") + } + if index < len(parts)-1 && !info.IsDir() { + return "", fmt.Errorf("logical key parent is not a directory") + } + } + return current, nil +} + func (executor *FileExecutor) metadata(scope string, key string, checksum string, size int64) FileMetadata { executor.mu.Lock() defer executor.mu.Unlock()