refactor(pkg/utils): add unified atomic file write utility

This commit is contained in:
mosir 2026-02-24 13:22:52 +08:00
parent 7cbfa89a96
commit c56fcedcb1
10 changed files with 166 additions and 71 deletions

View file

@ -12,6 +12,8 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"github.com/sipeed/picoclaw/pkg/utils"
) )
// MemoryStore manages persistent memory for the agent. // MemoryStore manages persistent memory for the agent.
@ -58,7 +60,9 @@ func (ms *MemoryStore) ReadLongTerm() string {
// WriteLongTerm writes content to the long-term memory file (MEMORY.md). // WriteLongTerm writes content to the long-term memory file (MEMORY.md).
func (ms *MemoryStore) WriteLongTerm(content string) error { func (ms *MemoryStore) WriteLongTerm(content string) error {
return os.WriteFile(ms.memoryFile, []byte(content), 0o644) // Use unified atomic write utility with explicit sync for flash storage reliability.
// Using 0o600 (owner read/write only) for secure default permissions.
return utils.WriteFileAtomic(ms.memoryFile, []byte(content), 0o600)
} }
// ReadToday reads today's daily note. // ReadToday reads today's daily note.
@ -78,7 +82,9 @@ func (ms *MemoryStore) AppendToday(content string) error {
// Ensure month directory exists // Ensure month directory exists
monthDir := filepath.Dir(todayFile) monthDir := filepath.Dir(todayFile)
os.MkdirAll(monthDir, 0o755) if err := os.MkdirAll(monthDir, 0o755); err != nil {
return err
}
var existingContent string var existingContent string
if data, err := os.ReadFile(todayFile); err == nil { if data, err := os.ReadFile(todayFile); err == nil {
@ -95,7 +101,8 @@ func (ms *MemoryStore) AppendToday(content string) error {
newContent = existingContent + "\n" + content newContent = existingContent + "\n" + content
} }
return os.WriteFile(todayFile, []byte(newContent), 0o644) // Use unified atomic write utility with explicit sync for flash storage reliability.
return utils.WriteFileAtomic(todayFile, []byte(newContent), 0o600)
} }
// GetRecentDailyNotes returns daily notes from the last N days. // GetRecentDailyNotes returns daily notes from the last N days.

View file

@ -5,6 +5,8 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
"github.com/sipeed/picoclaw/pkg/utils"
) )
type AuthCredential struct { type AuthCredential struct {
@ -63,16 +65,13 @@ func LoadStore() (*AuthStore, error) {
func SaveStore(store *AuthStore) error { func SaveStore(store *AuthStore) error {
path := authFilePath() path := authFilePath()
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(store, "", " ") data, err := json.MarshalIndent(store, "", " ")
if err != nil { if err != nil {
return err return err
} }
return os.WriteFile(path, data, 0o600)
// Use unified atomic write utility with explicit sync for flash storage reliability.
return utils.WriteFileAtomic(path, data, 0o600)
} }
func GetCredential(provider string) (*AuthCredential, error) { func GetCredential(provider string) (*AuthCredential, error) {

View file

@ -4,10 +4,10 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath"
"sync/atomic" "sync/atomic"
"github.com/caarlos0/env/v11" "github.com/caarlos0/env/v11"
"github.com/sipeed/picoclaw/pkg/utils"
) )
// rrCounter is a global counter for round-robin load balancing across models. // rrCounter is a global counter for round-robin load balancing across models.
@ -526,12 +526,8 @@ func SaveConfig(path string, cfg *Config) error {
return err return err
} }
dir := filepath.Dir(path) // Use unified atomic write utility with explicit sync for flash storage reliability.
if err := os.MkdirAll(dir, 0o755); err != nil { return utils.WriteFileAtomic(path, data, 0o600)
return err
}
return os.WriteFile(path, data, 0o600)
} }
func (c *Config) WorkspacePath() string { func (c *Config) WorkspacePath() string {

View file

@ -7,11 +7,11 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"path/filepath"
"sync" "sync"
"time" "time"
"github.com/adhocore/gronx" "github.com/adhocore/gronx"
"github.com/sipeed/picoclaw/pkg/utils"
) )
type CronSchedule struct { type CronSchedule struct {
@ -330,17 +330,13 @@ func (cs *CronService) loadStore() error {
} }
func (cs *CronService) saveStoreUnsafe() error { func (cs *CronService) saveStoreUnsafe() error {
dir := filepath.Dir(cs.storePath)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(cs.store, "", " ") data, err := json.MarshalIndent(cs.store, "", " ")
if err != nil { if err != nil {
return err return err
} }
return os.WriteFile(cs.storePath, data, 0o600) // Use unified atomic write utility with explicit sync for flash storage reliability.
return utils.WriteFileAtomic(cs.storePath, data, 0o600)
} }
func (cs *CronService) AddJob( func (cs *CronService) AddJob(

View file

@ -19,6 +19,7 @@ import (
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/state"
"github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/utils"
) )
const ( const (
@ -275,7 +276,7 @@ This file contains tasks for the heartbeat service to check periodically.
Add your heartbeat tasks below this line: Add your heartbeat tasks below this line:
` `
if err := os.WriteFile(heartbeatPath, []byte(defaultContent), 0o644); err != nil { if err := utils.WriteFileAtomic(heartbeatPath, []byte(defaultContent), 0o644); err != nil {
hs.logError("Failed to create default HEARTBEAT.md: %v", err) hs.logError("Failed to create default HEARTBEAT.md: %v", err)
} else { } else {
hs.logInfo("Created default HEARTBEAT.md template") hs.logInfo("Created default HEARTBEAT.md template")

View file

@ -9,6 +9,8 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
"github.com/sipeed/picoclaw/pkg/utils"
) )
type SkillInstaller struct { type SkillInstaller struct {
@ -64,7 +66,9 @@ func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) er
} }
skillPath := filepath.Join(skillDir, "SKILL.md") skillPath := filepath.Join(skillDir, "SKILL.md")
if err := os.WriteFile(skillPath, body, 0o644); err != nil {
// Use unified atomic write utility with explicit sync for flash storage reliability.
if err := utils.WriteFileAtomic(skillPath, body, 0o600); err != nil {
return fmt.Errorf("failed to write skill file: %w", err) return fmt.Errorf("failed to write skill file: %w", err)
} }

View file

@ -8,6 +8,8 @@ import (
"path/filepath" "path/filepath"
"sync" "sync"
"time" "time"
"github.com/sipeed/picoclaw/pkg/utils"
) )
// State represents the persistent state for a workspace. // State represents the persistent state for a workspace.
@ -124,33 +126,20 @@ func (sm *Manager) GetTimestamp() time.Time {
// saveAtomic performs an atomic save using temp file + rename. // saveAtomic performs an atomic save using temp file + rename.
// This ensures that the state file is never corrupted: // This ensures that the state file is never corrupted:
// 1. Write to a temp file // 1. Write to a temp file
// 2. Rename temp file to target (atomic on POSIX systems) // 2. Sync to disk (critical for SD cards/flash storage)
// 3. If rename fails, cleanup the temp file // 3. Rename temp file to target (atomic on POSIX systems)
// 4. If rename fails, cleanup the temp file
// //
// Must be called with the lock held. // Must be called with the lock held.
func (sm *Manager) saveAtomic() error { func (sm *Manager) saveAtomic() error {
// Create temp file in the same directory as the target // Use unified atomic write utility with explicit sync for flash storage reliability.
tempFile := sm.stateFile + ".tmp" // Using 0o600 (owner read/write only) for secure default permissions.
// Marshal state to JSON
data, err := json.MarshalIndent(sm.state, "", " ") data, err := json.MarshalIndent(sm.state, "", " ")
if err != nil { if err != nil {
return fmt.Errorf("failed to marshal state: %w", err) return fmt.Errorf("failed to marshal state: %w", err)
} }
// Write to temp file return utils.WriteFileAtomic(sm.stateFile, data, 0o600)
if err := os.WriteFile(tempFile, data, 0o644); err != nil {
return fmt.Errorf("failed to write temp file: %w", err)
}
// Atomic rename from temp to target
if err := os.Rename(tempFile, sm.stateFile); err != nil {
// Cleanup temp file if rename fails
os.Remove(tempFile)
return fmt.Errorf("failed to rename temp file: %w", err)
}
return nil
} }
// load loads the state from disk. // load loads the state from disk.

View file

@ -8,6 +8,8 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"github.com/sipeed/picoclaw/pkg/utils"
) )
// validatePath ensures the given path is within the workspace if restrict is true. // validatePath ensures the given path is within the workspace if restrict is true.
@ -276,25 +278,9 @@ func (h *hostFs) ReadDir(path string) ([]os.DirEntry, error) {
} }
func (h *hostFs) WriteFile(path string, data []byte) error { func (h *hostFs) WriteFile(path string, data []byte) error {
dir := filepath.Dir(path) // Use unified atomic write utility with explicit sync for flash storage reliability.
if err := os.MkdirAll(dir, 0o755); err != nil { // Using 0o600 (owner read/write only) for secure default permissions.
return fmt.Errorf("failed to create parent directories: %w", err) return utils.WriteFileAtomic(path, data, 0o600)
}
// We use a "write-then-rename" pattern here to ensure an atomic write.
// This prevents the target file from being left in a truncated or partial state
// if the operation is interrupted, as the rename operation is atomic on Linux.
tmpPath := fmt.Sprintf("%s.%d.tmp", path, time.Now().UnixNano())
if err := os.WriteFile(tmpPath, data, 0o644); err != nil {
os.Remove(tmpPath) // Ensure cleanup of partial/empty temp file
return fmt.Errorf("failed to write temp file: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("failed to replace original file: %w", err)
}
return nil
} }
// sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root. // sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root.
@ -351,14 +337,33 @@ func (r *sandboxFs) WriteFile(path string, data []byte) error {
} }
} }
// We use a "write-then-rename" pattern here to ensure an atomic write. // Use atomic write pattern with explicit sync for flash storage reliability.
// This prevents the target file from being left in a truncated or partial state // Using 0o600 (owner read/write only) for secure default permissions.
// if the operation is interrupted, as the rename operation is atomic on Linux. tmpRelPath := fmt.Sprintf(".tmp-%d.tmp", time.Now().UnixNano())
tmpRelPath := fmt.Sprintf("%s.%d.tmp", relPath, time.Now().UnixNano())
if err := root.WriteFile(tmpRelPath, data, 0o644); err != nil { tmpFile, err := root.OpenFile(tmpRelPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
root.Remove(tmpRelPath) // Ensure cleanup of partial/empty temp file if err != nil {
return fmt.Errorf("failed to write to temp file: %w", err) root.Remove(tmpRelPath)
return fmt.Errorf("failed to open temp file: %w", err)
}
if _, err := tmpFile.Write(data); err != nil {
tmpFile.Close()
root.Remove(tmpRelPath)
return fmt.Errorf("failed to write temp file: %w", err)
}
// CRITICAL: Force sync to storage medium before rename.
// This ensures data is physically written to disk, not just cached.
if err := tmpFile.Sync(); err != nil {
tmpFile.Close()
root.Remove(tmpRelPath)
return fmt.Errorf("failed to sync temp file: %w", err)
}
if err := tmpFile.Close(); err != nil {
root.Remove(tmpRelPath)
return fmt.Errorf("failed to close temp file: %w", err)
} }
if err := root.Rename(tmpRelPath, relPath); err != nil { if err := root.Rename(tmpRelPath, relPath); err != nil {

View file

@ -197,5 +197,6 @@ func writeOriginMeta(targetDir, registryName, slug, version string) error {
return err return err
} }
return os.WriteFile(filepath.Join(targetDir, ".skill-origin.json"), data, 0o644) // Use unified atomic write utility with explicit sync for flash storage reliability.
return utils.WriteFileAtomic(filepath.Join(targetDir, ".skill-origin.json"), data, 0o600)
} }

97
pkg/utils/file.go Normal file
View file

@ -0,0 +1,97 @@
// PicoClaw - Ultra-lightweight personal AI agent
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package utils
import (
"fmt"
"os"
"path/filepath"
)
// WriteFileAtomic atomically writes data to a file using a temp file + rename pattern.
//
// This guarantees that the target file is either:
// - Completely written with the new data
// - Unchanged (if write fails or power loss during write)
//
// The function:
// 1. Creates a temp file in the same directory
// 2. Writes data to temp file
// 3. Syncs to disk (critical for SD cards/flash storage)
// 4. Sets file permissions
// 5. Atomically renames temp file to target path
//
// Parameters:
// - path: Target file path
// - data: Data to write
// - perm: File permission mode (e.g., 0o600 for secure, 0o644 for readable)
//
// Returns:
// - Error if any step fails, nil on success
//
// Example:
//
// // Secure config file (owner read/write only)
// err := utils.WriteFileAtomic("config.json", data, 0o600)
//
// // Public readable file
// err := utils.WriteFileAtomic("public.txt", data, 0o644)
func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
// Create temp file in the same directory (ensures atomic rename works)
tmpFile, err := os.CreateTemp(dir, ".tmp-*.tmp")
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
tmpPath := tmpFile.Name()
// Cleanup on error: ensure temp file is removed if anything fails
cleanup := true
defer func() {
if cleanup {
_ = os.Remove(tmpPath)
}
}()
// Write data to temp file
if _, err := tmpFile.Write(data); err != nil {
tmpFile.Close()
return fmt.Errorf("failed to write temp file: %w", err)
}
// CRITICAL: Force sync to storage medium before rename.
// This ensures data is physically written to disk, not just cached.
// Essential for SD cards, eMMC, and other flash storage on edge devices.
if err := tmpFile.Sync(); err != nil {
tmpFile.Close()
return fmt.Errorf("failed to sync temp file: %w", err)
}
// Set file permissions
if err := tmpFile.Chmod(perm); err != nil {
tmpFile.Close()
return fmt.Errorf("failed to set permissions: %w", err)
}
// Close file before rename
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("failed to close temp file: %w", err)
}
// Atomic rename: temp file becomes the target
if err := os.Rename(tmpPath, path); err != nil {
return fmt.Errorf("failed to rename temp file: %w", err)
}
// Success: skip cleanup
cleanup = false
return nil
}