package runtime import ( "context" "encoding/json" "errors" "fmt" "io" "os" "path" "path/filepath" "strings" "sync" "time" "browser.local/run/protocol" ) type FileMetadata struct { Scope string `json:"scope"` Key string `json:"key"` Version int `json:"version"` Checksum string `json:"checksum"` SizeBytes int64 `json:"sizeBytes"` UpdatedAt time.Time `json:"updatedAt"` } type fileMetadataJournal struct { Version int `json:"version"` Records map[string]FileMetadata `json:"records"` } type FileExecutor struct { resolver WorkspaceResolver path string mu sync.Mutex records map[string]FileMetadata } func NewFileExecutor(workspaceRoot string) (*FileExecutor, error) { resolver := NewWorkspaceResolver(workspaceRoot) root, err := filepath.Abs(workspaceRoot) if err != nil { return nil, err } stateDir := filepath.Join(root, "state") if err := ensureDirectory(stateDir); err != nil { return nil, err } executor := &FileExecutor{resolver: resolver, path: filepath.Join(stateDir, "files.json"), records: map[string]FileMetadata{}} if err := executor.load(); err != nil { return nil, err } return executor, nil } func (executor *FileExecutor) Execute(ctx context.Context, assignment protocol.RunJobAssignment) LifecycleExecutionResult { if assignment.ServerInstanceID == "" || assignment.ExecutionInput.WorkspaceScope == "" { return lifecycleExecutionFailure("file_workspace_invalid", "file workspace scope is required", false) } 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, deploymentRoot, assignment) if result.ExecutionResult.Kind == "file" { result.ExecutionResult.Kind = "file.read" } return result } if assignment.Capability == protocol.RunCapabilityFilesList { return executor.list(ctx, scope, deploymentRoot, assignment) } if assignment.Capability == protocol.RunCapabilityConfigWrite || assignment.Capability == protocol.RunCapabilityFilesWrite { result := executor.write(ctx, scope, deploymentRoot, assignment) if result.ExecutionResult.Kind == "file" { result.ExecutionResult.Kind = "file.write" } return result } 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 } func (executor *FileExecutor) existingReadTargetForAssignment(assignment protocol.RunJobAssignment) (string, string, bool, error) { scope, deploymentRoot, err := executor.scopeForAssignment(assignment) if err != nil { return "", "", false, err } targetKey := fileTargetKeyForAssignment(assignment) if deploymentRoot && assignment.ExecutionInput.FileTargetKey == "" { targetKey = deploymentTargetKey(targetKey) filePath, err := existingDeploymentTarget(scope, targetKey) return scope, filePath, deploymentRoot, err } filePath, err := executor.resolver.ExistingTarget(scope, targetKey) return scope, filePath, deploymentRoot, err } type fileListEntry struct { Name string `json:"name"` Kind string `json:"kind"` RelativePath string `json:"relativePath"` LogicalKey string `json:"logicalKey"` SizeBytes int64 `json:"sizeBytes"` ModifiedAt string `json:"modifiedAt"` } type fileListEnvelope struct { DirectoryKey string `json:"directoryKey"` Path string `json:"path"` Entries []fileListEntry `json:"entries"` } 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) } directoryKey := assignment.TargetKey relativePath := strings.TrimSpace(assignment.ExecutionInput.Inputs["path"]) if relativePath == "" { relativePath = "." } if relativePath != "." && (!protocol.ValidLogicalFileKey(relativePath) || strings.Contains(relativePath, string(rune(92)))) { return lifecycleExecutionFailure("file_list_failed", "directory path is unsafe", false) } 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) } } info, err := os.Stat(directory) if err != nil || !info.IsDir() { return lifecycleExecutionFailure("file_list_failed", "target is not a directory", false) } query := strings.ToLower(strings.TrimSpace(assignment.ExecutionInput.Inputs["query"])) recursive := strings.EqualFold(assignment.ExecutionInput.Inputs["recursive"], "true") entries := make([]fileListEntry, 0, 32) resultLimit := assignment.ExecutionInput.MaxReadBytes if resultLimit <= 0 || resultLimit > maxExecutionContentBytes { resultLimit = maxExecutionContentBytes } visit := func(current string, item os.DirEntry) error { if err := ctx.Err(); err != nil { return err } name := item.Name() full := filepath.Join(current, name) rel, err := filepath.Rel(directory, full) if err != nil { return err } rel = filepath.ToSlash(rel) if query != "" && !strings.Contains(strings.ToLower(rel), query) { return nil } kind := "file" if item.IsDir() { kind = "directory" } else if !item.Type().IsRegular() { return nil } entryInfo, err := item.Info() if err != nil { return err } entryRelativePath := rel if relativePath != "." { entryRelativePath = path.Join(relativePath, rel) } logicalKey := path.Join(directoryKey, entryRelativePath) candidate := append(entries, fileListEntry{Name: name, Kind: kind, RelativePath: entryRelativePath, LogicalKey: logicalKey, SizeBytes: entryInfo.Size(), ModifiedAt: entryInfo.ModTime().UTC().Format(time.RFC3339Nano)}) body, marshalErr := json.Marshal(fileListEnvelope{DirectoryKey: directoryKey, Path: strings.TrimPrefix(relativePath, "."), Entries: candidate}) if marshalErr != nil { return marshalErr } if len(body) > resultLimit { return fmt.Errorf("file list exceeds approved read limit") } entries = candidate return nil } if recursive { err = filepath.WalkDir(directory, func(current string, item os.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } if current == directory { return nil } return visit(filepath.Dir(current), item) }) } else { var items []os.DirEntry items, err = os.ReadDir(directory) for _, item := range items { if err == nil { err = visit(directory, item) } if err != nil { break } } } if err != nil { if errors.Is(err, context.Canceled) { return lifecycleExecutionFailure("file_cancelled", "file list cancelled", false) } return lifecycleExecutionFailure("file_list_failed", err.Error(), false) } body, err := json.Marshal(fileListEnvelope{DirectoryKey: directoryKey, Path: strings.TrimPrefix(relativePath, "."), Entries: entries}) if err != nil { return lifecycleExecutionFailure("file_list_failed", "file list encoding failed", false) } 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, deploymentRoot bool, assignment protocol.RunJobAssignment) LifecycleExecutionResult { if err := ctx.Err(); err != nil { return lifecycleExecutionFailure("file_cancelled", "file read cancelled", false) } targetKey := fileTargetKeyForAssignment(assignment) if deploymentRoot && assignment.ExecutionInput.FileTargetKey == "" { 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) } limit := assignment.ExecutionInput.MaxReadBytes if limit <= 0 || limit > maxExecutionContentBytes { limit = maxExecutionContentBytes } 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(filePath) if err != nil { return lifecycleExecutionFailure("file_read_failed", err.Error(), false) } defer file.Close() content, err := io.ReadAll(io.LimitReader(file, int64(limit)+1)) if err != nil || len(content) > limit { return lifecycleExecutionFailure("file_read_too_large", "file exceeds approved read limit", false) } checksum := bytesChecksum(content) metadata := executor.metadata(scope, assignment.TargetKey, checksum, int64(len(content))) 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, 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) } if err := ctx.Err(); err != nil { return lifecycleExecutionFailure("file_cancelled", "file write cancelled", false) } targetKey := fileTargetKeyForAssignment(assignment) if deploymentRoot && assignment.ExecutionInput.FileTargetKey == "" { 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, filePath) if err != nil { return lifecycleExecutionFailure("file_metadata_failed", err.Error(), false) } if current.Version == 0 && assignment.ExecutionInput.ExpectedVersion > 0 { current.Version = assignment.ExecutionInput.ExpectedVersion current.Checksum = assignment.ExecutionInput.ExpectedChecksum } if assignment.ExecutionInput.ExpectedVersion > 0 && current.Version != assignment.ExecutionInput.ExpectedVersion { return lifecycleExecutionFailure("file_version_conflict", "expected version does not match current file", false) } if assignment.ExecutionInput.ExpectedChecksum != "" && current.Checksum != assignment.ExecutionInput.ExpectedChecksum { return lifecycleExecutionFailure("file_checksum_conflict", "expected checksum does not match current file", false) } if err := ctx.Err(); err != nil { return lifecycleExecutionFailure("file_cancelled", "file write cancelled", false) } if err := ensureDirectory(parent); err != nil { return lifecycleExecutionFailure("file_target_invalid", err.Error(), false) } temporary, err := os.CreateTemp(parent, ".run-write-*") if err != nil { return lifecycleExecutionFailure("file_write_failed", err.Error(), false) } temporaryName := temporary.Name() defer os.Remove(temporaryName) if err := temporary.Chmod(0o600); err != nil { _ = temporary.Close() return lifecycleExecutionFailure("file_write_failed", err.Error(), false) } if _, err := temporary.Write(content); err != nil { _ = temporary.Close() return lifecycleExecutionFailure("file_write_failed", err.Error(), false) } if err := temporary.Sync(); err != nil { _ = temporary.Close() return lifecycleExecutionFailure("file_write_failed", err.Error(), false) } if err := temporary.Close(); err != nil { return lifecycleExecutionFailure("file_write_failed", err.Error(), false) } if err := ctx.Err(); err != nil { return lifecycleExecutionFailure("file_cancelled", "file write cancelled", false) } if err := os.Rename(temporaryName, filePath); err != nil { return lifecycleExecutionFailure("file_write_failed", err.Error(), false) } checksum := bytesChecksum(content) next := FileMetadata{Scope: scope, Key: assignment.TargetKey, Version: current.Version + 1, Checksum: checksum, SizeBytes: int64(len(content)), UpdatedAt: time.Now().UTC()} executor.records[metadataKey(scope, assignment.TargetKey)] = next if err := executor.persistLocked(); err != nil { return lifecycleExecutionFailure("file_metadata_failed", err.Error(), false) } 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 == "" { 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() item := executor.records[metadataKey(scope, key)] if item.Version == 0 { item = FileMetadata{Scope: scope, Key: key, Version: 1} } item.Checksum, item.SizeBytes, item.UpdatedAt = checksum, size, time.Now().UTC() executor.records[metadataKey(scope, key)] = item _ = executor.persistLocked() return item } func (executor *FileExecutor) currentMetadataLocked(scope string, key string, path string) (FileMetadata, error) { item := executor.records[metadataKey(scope, key)] info, err := os.Lstat(path) if err != nil && !os.IsNotExist(err) { return FileMetadata{}, err } if err == nil { if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { return FileMetadata{}, fmt.Errorf("target must be a regular file") } body, readErr := os.ReadFile(path) if readErr != nil { return FileMetadata{}, readErr } checksum := bytesChecksum(body) if item.Version == 0 { item.Version = 1 } item.Scope, item.Key, item.Checksum, item.SizeBytes = scope, key, checksum, int64(len(body)) } else if item.Version == 0 { item.Scope, item.Key = scope, key } return item, nil } func (executor *FileExecutor) load() error { body, err := os.ReadFile(executor.path) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return err } var file fileMetadataJournal if err := json.Unmarshal(body, &file); err != nil { return fmt.Errorf("decode file metadata journal: %w", err) } for key, item := range file.Records { executor.records[key] = item } return nil } func (executor *FileExecutor) persistLocked() error { body, err := json.Marshal(fileMetadataJournal{Version: 1, Records: executor.records}) if err != nil { return err } temporary := executor.path + ".tmp" if err := os.WriteFile(temporary, body, 0o600); err != nil { return err } if err := os.Rename(temporary, executor.path); err != nil { _ = os.Remove(temporary) return err } return nil } func metadataKey(scope string, key string) string { return scope + "\x00" + key }