543 lines
20 KiB
Go
543 lines
20 KiB
Go
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 }
|