Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pkg/unikontainers/unikontainers.go
Original file line number Diff line number Diff line change
Expand Up @@ -706,7 +706,7 @@ func (u *Unikontainer) saveContainerState() error {
}

stateName := filepath.Join(u.BaseDir, stateFilename)
return os.WriteFile(stateName, data, 0o644) //nolint: gosec
return atomicWriteFile(stateName, data, 0o644)
}

// getHooksByName returns the hooks for a given lifecycle stage
Expand Down
50 changes: 37 additions & 13 deletions pkg/unikontainers/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,22 +106,46 @@ func loadSpec(bundleDir string) (*specs.Spec, error) {
return &spec, nil
}

// writePidFile writes the content of pid to the file defined by path
func writePidFile(path string, pid int) error {
var (
tmpDir = filepath.Dir(path)
tmpName = filepath.Join(tmpDir, "."+filepath.Base(path))
)
f, err := os.OpenFile(tmpName, os.O_RDWR|os.O_CREATE|os.O_EXCL|os.O_SYNC, 0o666)
// atomicWriteFile writes data to a file atomically by first writing to a
// temporary file in the same directory, syncing it, and then renaming it
// to the target path. This prevents partial/corrupt files if the process
// is killed mid-write.
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
tmpName := filepath.Join(dir, "."+filepath.Base(path)+".tmp")

f, err := os.OpenFile(tmpName, os.O_RDWR|os.O_CREATE|os.O_TRUNC, perm)
if err != nil {
Comment thread
cmainas marked this conversation as resolved.
return err
return fmt.Errorf("failed to create temp file: %w", err)
}
_, err = f.WriteString(strconv.Itoa(pid))
f.Close()
if err != nil {
return err

_, writeErr := f.Write(data)
syncErr := f.Sync()
closeErr := f.Close()

if writeErr != nil {
os.Remove(tmpName)
return fmt.Errorf("failed to write temp file: %w", writeErr)
}
if syncErr != nil {
os.Remove(tmpName)
return fmt.Errorf("failed to sync temp file: %w", syncErr)
}
return os.Rename(tmpName, path)
if closeErr != nil {
os.Remove(tmpName)
return fmt.Errorf("failed to close temp file: %w", closeErr)
}

if err := os.Rename(tmpName, path); err != nil {
os.Remove(tmpName)
return fmt.Errorf("failed to rename temp file to %s: %w", path, err)
}
return nil
}

// writePidFile writes the content of pid to the file defined by path
func writePidFile(path string, pid int) error {
return atomicWriteFile(path, []byte(strconv.Itoa(pid)), 0o666)
}

// handleQueueProxy adds a hardcoded IP to the process's environment.
Expand Down
73 changes: 73 additions & 0 deletions pkg/unikontainers/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,79 @@ import (
"github.com/stretchr/testify/assert"
)

func TestAtomicWriteFile(t *testing.T) {
t.Run("writes file atomically", func(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
target := filepath.Join(tmpDir, "state.json")
data := []byte(`{"status":"running"}`)

err := atomicWriteFile(target, data, 0o644)
assert.NoError(t, err)

content, err := os.ReadFile(target)
assert.NoError(t, err)
assert.Equal(t, data, content)
})

t.Run("overwrites existing file", func(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
target := filepath.Join(tmpDir, "state.json")

err := os.WriteFile(target, []byte("old"), 0o600)
assert.NoError(t, err)

newData := []byte("new content")
err = atomicWriteFile(target, newData, 0o644)
assert.NoError(t, err)

content, err := os.ReadFile(target)
assert.NoError(t, err)
assert.Equal(t, newData, content)
})

t.Run("no temp file left on success", func(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
target := filepath.Join(tmpDir, "state.json")

err := atomicWriteFile(target, []byte("data"), 0o644)
assert.NoError(t, err)

tmpFile := filepath.Join(tmpDir, ".state.json.tmp")
_, err = os.Stat(tmpFile)
assert.True(t, os.IsNotExist(err), "Temp file should not exist after successful write")
})

t.Run("fails on invalid directory", func(t *testing.T) {
t.Parallel()
target := filepath.Join("/nonexistent/dir", "state.json")

err := atomicWriteFile(target, []byte("data"), 0o644)
assert.Error(t, err)
})
Comment on lines +73 to +79
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TestAtomicWriteFile covers the success path and a create-temp failure, but it doesn’t cover the rename-failure path (e.g., when the target exists as a directory or permissions prevent replacing it). Adding a test for rename failure is useful to verify temp-file cleanup and to prevent accumulating stray temp files over time.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is also correct.


t.Run("rename failure cleans up temp file", func(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()

// Create a directory at the target path so rename fails.
target := filepath.Join(tmpDir, "state.json")
err := os.Mkdir(target, 0o755)
assert.NoError(t, err)

err = atomicWriteFile(target, []byte("data"), 0o644)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to rename temp file")

// Verify the temp file was cleaned up.
tmpFile := filepath.Join(tmpDir, ".state.json.tmp")
_, err = os.Stat(tmpFile)
assert.True(t, os.IsNotExist(err), "Temp file should be cleaned up after rename failure")
})
}

func TestWritePidFile(t *testing.T) {
tmpDir := t.TempDir() // Create a temporary directory for the test
pidFilePath := filepath.Join(tmpDir, "test.pid")
Expand Down
Loading