Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
1daddb6
Safer file io for configuration files
tonistiigi Apr 21, 2016
9f03097
Set permission on atomic file write
dmcgowan Jun 29, 2016
48cfa01
Update layer store to sync transaction files before committing
dmcgowan Aug 9, 2016
68bbe0a
pkg/*: clean up a few issues
unclejack Mar 30, 2017
600a2ab
Add canonical import comment
dnephin Feb 5, 2018
2c26a9e
unconvert: remove unnescessary conversions
thaJeztah Aug 7, 2019
af79dfd
Merge pull request #39668 from thaJeztah/replace_gometalinter
yongtang Sep 18, 2019
108c158
refactor: move from io/ioutil to io and os package
Juneezee Aug 24, 2021
98088d2
pkg/*: fix "empty-lines" (revive)
thaJeztah Sep 23, 2022
5bc8fac
Merge pull request #44211 from thaJeztah/more_linters_step1
thaJeztah Sep 28, 2022
f6bd355
pkg/ioutils: format code with gofumpt
thaJeztah Jan 20, 2022
5aa2f9d
pkg/ioutils: some cleanups in tests
thaJeztah Jul 12, 2023
d4f48bc
Update GoDoc for ioutils on atomic writers
crazybolillo Apr 3, 2024
26657e1
pkg/ioutils: move atomic file-writers to a separate (pkg/atomicwriter…
thaJeztah Dec 28, 2024
37e3991
pkg/atomicwriter: New(): prevent creating temp-file on errors
thaJeztah Mar 9, 2025
adaf3d2
pkg/atomicwriter: New(): use absolute path for temp-file
thaJeztah Mar 9, 2025
d09d225
pkg/atomicwriter: refactor tests
thaJeztah Mar 10, 2025
d27bf51
pkg/atomicwriter: add separate tests for New()
thaJeztah Mar 10, 2025
d0c17c2
pkg/atomicwriter: don't overwrite destination on close without write
thaJeztah Mar 10, 2025
2da7e4f
pkg/atomicwriter: add additional test-cases
thaJeztah Mar 10, 2025
306cc18
pkg/atomicwriter: validate destination path
thaJeztah Mar 10, 2025
0cd5512
pkg/atomicwriter: use sequential file access on Windows
thaJeztah Mar 9, 2025
da51f40
pkg/atomicwriter: add basic godoc for package
thaJeztah Apr 3, 2025
f977a5e
pkg/atomicwriter: disallow symlinked files for now
thaJeztah Apr 3, 2025
66f215a
pkg/atomicwriter: error on unknown file-modes
thaJeztah Apr 3, 2025
ab8938c
pkg/atomicwriter: add test for parent dir not being a directory
thaJeztah Apr 3, 2025
145deb8
pkg/atomicwriter: return early if parent directory is invalid
thaJeztah Apr 3, 2025
a6695f6
integrate pkg/atomicwriter
thaJeztah Apr 4, 2025
62a361a
atomicwriter: add go.mod
thaJeztah Apr 1, 2025
17fe011
atomicwriter: add to Makefile and GitHub actions
thaJeztah Apr 1, 2025
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 .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
run: |
# This corresponds with the list in Makefile:1, but omits the "userns"
# and "capability" modules, which require go1.21 as minimum.
echo 'PACKAGES=mountinfo mount reexec sequential signal symlink user' >> $GITHUB_ENV
echo 'PACKAGES=atomicwriter mountinfo mount reexec sequential signal symlink user' >> $GITHUB_ENV
- name: go mod tidy
run: |
make foreach CMD="go mod tidy"
Expand Down
15 changes: 11 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
PACKAGES ?= capability mountinfo mount reexec sequential signal symlink user userns # IMPORTANT: when updating this list, also update the conditional one in .github/workflows/test.yml
PACKAGES ?= atomicwriter capability mountinfo mount reexec sequential signal symlink user userns # IMPORTANT: when updating this list, also update the conditional one in .github/workflows/test.yml
BINDIR ?= _build/bin
CROSS ?= linux/arm linux/arm64 linux/ppc64le linux/s390x \
freebsd/amd64 openbsd/amd64 darwin/amd64 darwin/arm64 windows/amd64
Expand Down Expand Up @@ -29,16 +29,23 @@ test: test-local
test: CMD=go test $(RUN_VIA_SUDO) -v -coverprofile=coverage.txt -covermode=atomic .
test: foreach

# Test the mount module against the local mountinfo source code instead of the
# release specified in its go.mod. This allows catching regressions / breaking
# changes in mountinfo.
# Some modules in this repo have interdependencies:
# - mount depends on mountinfo
# - atomicwrite depends on sequential
#
# The code below tests these modules against their local dependencies
# to catch regressions / breaking changes early.
.PHONY: test-local
test-local: MOD = -modfile=go-local.mod
test-local:
echo 'replace github.com/moby/sys/mountinfo => ../mountinfo' | cat mount/go.mod - > mount/go-local.mod
# Run go mod tidy to make sure mountinfo dependency versions are met.
cd mount && go mod tidy $(MOD) && go test $(MOD) $(RUN_VIA_SUDO) -v .
$(RM) mount/go-local.*
echo 'replace github.com/moby/sys/sequential => ../sequential' | cat atomicwriter/go.mod - > atomicwriter/go-local.mod
# Run go mod tidy to make sure sequential dependency versions are met.
cd atomicwriter && go mod tidy $(MOD) && go test $(MOD) $(RUN_VIA_SUDO) -v .
$(RM) atomicwriter/go-local.*

.PHONY: lint
lint: $(BINDIR)/golangci-lint
Expand Down
245 changes: 245 additions & 0 deletions atomicwriter/atomicwriter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
// Package atomicwriter provides utilities to perform atomic writes to a
// file or set of files.
package atomicwriter

import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"syscall"

"github.com/moby/sys/sequential"
)

func validateDestination(fileName string) error {
if fileName == "" {
return errors.New("file name is empty")
}
if dir := filepath.Dir(fileName); dir != "" && dir != "." && dir != ".." {
di, err := os.Stat(dir)
if err != nil {
return fmt.Errorf("invalid output path: %w", err)
}
if !di.IsDir() {
return fmt.Errorf("invalid output path: %w", &os.PathError{Op: "stat", Path: dir, Err: syscall.ENOTDIR})
}
}

// Deliberately using Lstat here to match the behavior of [os.Rename],
// which is used when completing the write and does not resolve symlinks.
fi, err := os.Lstat(fileName)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("failed to stat output path: %w", err)
}

switch mode := fi.Mode(); {
case mode.IsRegular():
return nil // Regular file
case mode&os.ModeDir != 0:
return errors.New("cannot write to a directory")
case mode&os.ModeSymlink != 0:
return errors.New("cannot write to a symbolic link directly")
case mode&os.ModeNamedPipe != 0:
return errors.New("cannot write to a named pipe (FIFO)")
case mode&os.ModeSocket != 0:
return errors.New("cannot write to a socket")
case mode&os.ModeDevice != 0:
if mode&os.ModeCharDevice != 0 {
return errors.New("cannot write to a character device file")
}
return errors.New("cannot write to a block device file")
case mode&os.ModeSetuid != 0:
return errors.New("cannot write to a setuid file")
case mode&os.ModeSetgid != 0:
return errors.New("cannot write to a setgid file")
case mode&os.ModeSticky != 0:
return errors.New("cannot write to a sticky bit file")
default:
return fmt.Errorf("unknown file mode: %[1]s (%#[1]o)", mode)
}
}

// New returns a WriteCloser so that writing to it writes to a
// temporary file and closing it atomically changes the temporary file to
// destination path. Writing and closing concurrently is not allowed.
// NOTE: umask is not considered for the file's permissions.
//
// New uses [sequential.CreateTemp] to use sequential file access on Windows,
// avoiding depleting the standby list un-necessarily. On Linux, this equates to
// a regular [os.CreateTemp]. Refer to the [Win32 API documentation] for details
// on sequential file access.
//
// [Win32 API documentation]: https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilea#FILE_FLAG_SEQUENTIAL_SCAN
func New(filename string, perm os.FileMode) (io.WriteCloser, error) {
if err := validateDestination(filename); err != nil {
return nil, err
}
abspath, err := filepath.Abs(filename)
if err != nil {
return nil, err
}

f, err := sequential.CreateTemp(filepath.Dir(abspath), ".tmp-"+filepath.Base(filename))
if err != nil {
return nil, err
}
return &atomicFileWriter{
f: f,
fn: abspath,
perm: perm,
}, nil
}

// WriteFile atomically writes data to a file named by filename and with the
// specified permission bits. The given filename is created if it does not exist,
// but the destination directory must exist. It can be used as a drop-in replacement
// for [os.WriteFile], but currently does not allow the destination path to be
// a symlink. WriteFile is implemented using [New] for its implementation.
//
// NOTE: umask is not considered for the file's permissions.
func WriteFile(filename string, data []byte, perm os.FileMode) error {
f, err := New(filename, perm)
if err != nil {
return err
}
n, err := f.Write(data)
if err == nil && n < len(data) {
err = io.ErrShortWrite
f.(*atomicFileWriter).writeErr = err
}
if err1 := f.Close(); err == nil {
err = err1
}
return err
}

type atomicFileWriter struct {
f *os.File
fn string
writeErr error
written bool
perm os.FileMode
}

func (w *atomicFileWriter) Write(dt []byte) (int, error) {
w.written = true
n, err := w.f.Write(dt)
if err != nil {
w.writeErr = err
}
return n, err
}

func (w *atomicFileWriter) Close() (retErr error) {
defer func() {
if err := os.Remove(w.f.Name()); !errors.Is(err, os.ErrNotExist) && retErr == nil {
retErr = err
}
}()
if err := w.f.Sync(); err != nil {
_ = w.f.Close()
return err
}
if err := w.f.Close(); err != nil {
return err
}
if err := os.Chmod(w.f.Name(), w.perm); err != nil {
return err
}
if w.writeErr == nil && w.written {
return os.Rename(w.f.Name(), w.fn)
}
return nil
}

// WriteSet is used to atomically write a set
// of files and ensure they are visible at the same time.
// Must be committed to a new directory.
type WriteSet struct {
root string
}

// NewWriteSet creates a new atomic write set to
// atomically create a set of files. The given directory
// is used as the base directory for storing files before
// commit. If no temporary directory is given the system
// default is used.
func NewWriteSet(tmpDir string) (*WriteSet, error) {
td, err := os.MkdirTemp(tmpDir, "write-set-")
if err != nil {
return nil, err
}

return &WriteSet{
root: td,
}, nil
}

// WriteFile writes a file to the set, guaranteeing the file
// has been synced.
func (ws *WriteSet) WriteFile(filename string, data []byte, perm os.FileMode) error {
f, err := ws.FileWriter(filename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
if err != nil {
return err
}
n, err := f.Write(data)
if err == nil && n < len(data) {
err = io.ErrShortWrite
}
if err1 := f.Close(); err == nil {
err = err1
}
return err
}

type syncFileCloser struct {
*os.File
}

func (w syncFileCloser) Close() error {
err := w.File.Sync()
if err1 := w.File.Close(); err == nil {
err = err1
}
return err
}

// FileWriter opens a file writer inside the set. The file
// should be synced and closed before calling commit.
//
// FileWriter uses [sequential.OpenFile] to use sequential file access on Windows,
// avoiding depleting the standby list un-necessarily. On Linux, this equates to
// a regular [os.OpenFile]. Refer to the [Win32 API documentation] for details
// on sequential file access.
//
// [Win32 API documentation]: https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilea#FILE_FLAG_SEQUENTIAL_SCAN
func (ws *WriteSet) FileWriter(name string, flag int, perm os.FileMode) (io.WriteCloser, error) {
f, err := sequential.OpenFile(filepath.Join(ws.root, name), flag, perm)
if err != nil {
return nil, err
}
return syncFileCloser{f}, nil
}

// Cancel cancels the set and removes all temporary data
// created in the set.
func (ws *WriteSet) Cancel() error {
return os.RemoveAll(ws.root)
}

// Commit moves all created files to the target directory. The
// target directory must not exist and the parent of the target
// directory must exist.
func (ws *WriteSet) Commit(target string) error {
return os.Rename(ws.root, target)
}

// String returns the location the set is writing to.
func (ws *WriteSet) String() string {
return ws.root
}
Loading
Loading