Decouple checkpoint storage from workspace state
This commit is contained in:
102
internal/core/fileio/fileio.go
Normal file
102
internal/core/fileio/fileio.go
Normal file
@@ -0,0 +1,102 @@
|
||||
// Package fileio provides confined, atomic artifact writes.
|
||||
package fileio
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func SafePath(root, name string) (string, error) {
|
||||
root = strings.TrimSpace(root)
|
||||
if root == "" {
|
||||
return "", fmt.Errorf("file root must not be empty")
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("artifact name must not be empty")
|
||||
}
|
||||
if strings.Contains(name, `\\`) {
|
||||
return "", fmt.Errorf("artifact name %q must use slash-separated relative paths", name)
|
||||
}
|
||||
if path.IsAbs(name) || filepath.IsAbs(name) {
|
||||
return "", fmt.Errorf("artifact name %q must be relative", name)
|
||||
}
|
||||
if name == "." || strings.Contains(name, "..") {
|
||||
return "", fmt.Errorf("artifact name %q must not contain ..", name)
|
||||
}
|
||||
if path.Clean(name) != name {
|
||||
return "", fmt.Errorf("artifact name %q must be clean", name)
|
||||
}
|
||||
absRoot, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve file root %q: %w", root, err)
|
||||
}
|
||||
target, err := filepath.Abs(filepath.Join(absRoot, filepath.FromSlash(name)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve artifact %q: %w", name, err)
|
||||
}
|
||||
rel, err := filepath.Rel(absRoot, target)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve artifact %q: %w", name, err)
|
||||
}
|
||||
if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("artifact name %q resolves outside file root", name)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func WriteJSON(root, name string, payload any, dirMode, fileMode os.FileMode) error {
|
||||
data, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal artifact %q: %w", name, err)
|
||||
}
|
||||
return WriteBytes(root, name, append(data, '\n'), dirMode, fileMode)
|
||||
}
|
||||
|
||||
func WriteBytes(root, name string, data []byte, dirMode, fileMode os.FileMode) error {
|
||||
target, err := SafePath(root, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAtomic(target, data, dirMode, fileMode); err != nil {
|
||||
return fmt.Errorf("write artifact %q: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeAtomic(target string, data []byte, dirMode, fileMode os.FileMode) error {
|
||||
if err := os.MkdirAll(filepath.Dir(target), dirMode); err != nil {
|
||||
return err
|
||||
}
|
||||
temp, err := os.CreateTemp(filepath.Dir(target), "."+filepath.Base(target)+".tmp-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tempPath := temp.Name()
|
||||
keep := true
|
||||
defer func() {
|
||||
if keep {
|
||||
_ = os.Remove(tempPath)
|
||||
}
|
||||
}()
|
||||
if _, err := temp.Write(data); err != nil {
|
||||
_ = temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Chmod(fileMode); err != nil {
|
||||
_ = temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tempPath, target); err != nil {
|
||||
return err
|
||||
}
|
||||
keep = false
|
||||
return nil
|
||||
}
|
||||
41
internal/core/fileio/fileio_test.go
Normal file
41
internal/core/fileio/fileio_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package fileio
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSafePathRejectsUnsafeNames(t *testing.T) {
|
||||
for _, name := range []string{"/tmp/x", "a/../x", "a//x", `a\\x`} {
|
||||
if _, err := SafePath(t.TempDir(), name); err == nil {
|
||||
t.Fatalf("SafePath(%q) accepted unsafe path", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteBytesIsAtomicAndUsesRequestedModes(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := WriteBytes(root, "nested/value", []byte("value"), 0o700, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for path, want := range map[string]os.FileMode{filepath.Join(root, "nested"): 0o700, filepath.Join(root, "nested", "value"): 0o600} {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != want {
|
||||
t.Fatalf("%s mode=%#o want %#o", path, info.Mode().Perm(), want)
|
||||
}
|
||||
}
|
||||
entries, err := os.ReadDir(filepath.Join(root, "nested"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.Contains(entry.Name(), ".tmp-") {
|
||||
t.Fatalf("temporary file remains: %s", entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user