Honor deployment root for file operations
This commit is contained in:
@@ -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)
|
||||
|
||||
+152
-13
@@ -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 != "." {
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user