Initial commit after importing from github

This commit is contained in:
Laurence Withers 2020-01-02 09:47:21 +00:00
commit 61c1049e28
5 changed files with 426 additions and 0 deletions

39
README.md Normal file
View File

@ -0,0 +1,39 @@
# src.lwithers.me.uk/go/writefile
This package implements the common Unix technique of writing a new file by first
writing a temporary file, then ensuring it is fsync()ed, before calling rename()
to atomically move into its final resting place.
What problem does this solve? It prevents writing a partial output file, or
corrupting an existing file with a partially-written new version, if there is a
crash or other unexpected exit of the program.
Example:
```
finalFname, out, err := writefile.New("my-file")
if err != nil {
return err
}
defer writefile.Abort(out)
// … write to out
return writefile.Commit(finalFname, out)
```
The `New()` method returns a final filename string which is later passed to
`Commit()`. This will typically match the filename argument passed to `New()`,
except if the file is a symlink, in which case the symlink is dereferenced
and its target is overwritten. If this behaviour is undesired, the following
pattern may be used instead:
```
out, err := writefile.NewNoDeref("my-file")
// …
return writefile.Commit("my-file", out)
```
The temporary file being written will have 0600 permissions. If an existing
file is overwritten by `Commit()`, the call will attempt to inherit the existing
permissions. Otherwise, the final file is left with 0600.

5
go.mod Normal file
View File

@ -0,0 +1,5 @@
module src.lwithers.me.uk/go/writefile
go 1.13
require golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8

2
go.sum Normal file
View File

@ -0,0 +1,2 @@
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8 h1:JA8d3MPx/IToSyXZG/RhwYEtfrKO1Fxrqe8KrkiLXKM=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

264
unit_test.go Normal file
View File

@ -0,0 +1,264 @@
package writefile
import (
"bytes"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"testing"
)
var (
tmpdir string
tmpfileCount int
)
func TestMain(m *testing.M) {
var err error
if tmpdir, err = ioutil.TempDir("", "tmpfile-unit-test"); err != nil {
fmt.Fprintf(os.Stderr, "Could not create temporary "+
"directory: %v\n", err)
os.Exit(1)
}
ret := m.Run()
os.RemoveAll(tmpdir)
os.Exit(ret)
}
func TestCommit(t *testing.T) {
expData := []byte("test")
ff, tmpf := makeTempFile(t, "commit", "commit")
if _, err := tmpf.Write(expData); err != nil {
t.Errorf("error writing to temporary file: %v", err)
}
if err := Commit(ff, tmpf); err != nil {
t.Errorf("error committing temporary file: %v", err)
}
checkFile(t, "final", ff, expData)
}
func TestOverwrite(t *testing.T) {
origData := []byte("original")
expData := []byte("testing")
// write out original data
ff := filepath.Join(tmpdir, "overwrite")
if err := ioutil.WriteFile(ff, origData, 0666); err != nil {
t.Fatalf("could not prepare file %s: %v", ff, err)
}
// open a temporary file — should not overwrite our original
_, tmpf := makeTempFile(t, "overwrite", "overwrite")
checkFile(t, "tmpfile open", ff, origData)
// write some data to temporary file, and verify original still intact
if _, err := tmpf.Write(expData); err != nil {
t.Errorf("error writing to temporary file: %v", err)
}
checkFile(t, "tmpfile modified", ff, origData)
// now Commit — original should be overwritten
if err := Commit(ff, tmpf); err != nil {
t.Errorf("error committing temporary file: %v", err)
}
checkFile(t, "tmpfile committed", ff, expData)
}
func TestAbort(t *testing.T) {
origData := []byte("original")
expData := []byte("testing")
// write out original data
ff := filepath.Join(tmpdir, "overwrite_abort")
if err := ioutil.WriteFile(ff, origData, 0666); err != nil {
t.Fatalf("could not prepare file %s: %v", ff, err)
}
// open a temporary file — should not overwrite our original
_, tmpf := makeTempFile(t, "overwrite_abort", "overwrite_abort")
checkFile(t, "tmpfile open", ff, origData)
// write some data to temporary file, and verify original still intact
if _, err := tmpf.Write(expData); err != nil {
t.Errorf("error writing to temporary file: %v", err)
}
checkFile(t, "tmpfile modified", ff, origData)
// now Abort — original should not be overwritten
Abort(tmpf)
checkFile(t, "tmpfile aborted", ff, origData)
if err := tmpf.Close(); err == nil {
t.Error("expected error when closing aborted file")
}
}
func TestSymlinkRel(t *testing.T) {
origData := []byte("original")
expData := []byte("testing")
// write out original data
ff := filepath.Join(tmpdir, "sym_target")
if err := ioutil.WriteFile(ff, origData, 0666); err != nil {
t.Fatalf("could not prepare file %s: %v", ff, err)
}
// create a symlink
tgt := filepath.Join(tmpdir, "sym")
if err := os.Symlink("sym_target", tgt); err != nil {
t.Fatalf("could not prepare symlink %s: %v", tgt, err)
}
// open a temporary file — should not overwrite our original
_, tmpf := makeTempFile(t, "sym", "sym_target")
checkFile(t, "tmpfile open", ff, origData)
// write some data to temporary file, and verify original still intact
if _, err := tmpf.Write(expData); err != nil {
t.Errorf("error writing to temporary file: %v", err)
}
checkFile(t, "tmpfile modified", ff, origData)
// now Commit — original should be overwritten
if err := Commit(ff, tmpf); err != nil {
t.Errorf("error committing temporary file: %v", err)
}
checkFile(t, "tmpfile committed", ff, expData)
}
func TestSymlinkAbs(t *testing.T) {
origData := []byte("original")
expData := []byte("testing")
// write out original data
ff := filepath.Join(tmpdir, "sym_target_abs")
if err := ioutil.WriteFile(ff, origData, 0666); err != nil {
t.Fatalf("could not prepare file %s: %v", ff, err)
}
// create a symlink
tgt := filepath.Join(tmpdir, "sym_abs")
if err := os.Symlink(filepath.Join(tmpdir, "sym_target_abs"), tgt); err != nil {
t.Fatalf("could not prepare symlink %s: %v", tgt, err)
}
// open a temporary file — should not overwrite our original
_, tmpf := makeTempFile(t, "sym_abs", "sym_target_abs")
checkFile(t, "tmpfile open", ff, origData)
// write some data to temporary file, and verify original still intact
if _, err := tmpf.Write(expData); err != nil {
t.Errorf("error writing to temporary file: %v", err)
}
checkFile(t, "tmpfile modified", ff, origData)
// now Commit — original should be overwritten
if err := Commit(ff, tmpf); err != nil {
t.Errorf("error committing temporary file: %v", err)
}
checkFile(t, "tmpfile committed", ff, expData)
}
func TestSymlinkEnoent(t *testing.T) {
expData := []byte("testing")
// create a symlink (pointing at a file which doesn't exist)
tgt := filepath.Join(tmpdir, "sym_enoent")
if err := os.Symlink("enoent", tgt); err != nil {
t.Fatalf("could not prepare symlink %s: %v", tgt, err)
}
// open a temporary file
ff, tmpf := makeTempFile(t, "sym_enoent", "enoent")
// write some data to temporary file, commit and verify
if _, err := tmpf.Write(expData); err != nil {
t.Errorf("error writing to temporary file: %v", err)
}
if err := Commit(ff, tmpf); err != nil {
t.Errorf("error committing temporary file: %v", err)
}
checkFile(t, "tmpfile committed", ff, expData)
}
func TestSymlinkLoop(t *testing.T) {
a := filepath.Join(tmpdir, "symlink_loop_a")
b := filepath.Join(tmpdir, "symlink_loop_b")
if err := os.Symlink(a, b); err != nil {
t.Fatalf("failed to create symlink %s→%s: %v", a, b, err)
}
if err := os.Symlink(b, a); err != nil {
t.Fatalf("failed to create symlink %s→%s: %v", b, a, err)
}
ff, tmpf, err := New(a)
switch err := err.(type) {
case nil:
t.Errorf("error expected, but New succeeded (%q/%q)",
ff, tmpf.Name())
case *os.PathError:
if err.Path != a {
t.Errorf("got *os.PathError with unexpected path "+
"(got %#v, expected %q)", err, a)
}
default:
t.Errorf("got unexpected error type %T (%v)", err, err)
}
}
func TestSymlinkDir(t *testing.T) {
dnam := filepath.Join(tmpdir, "target_dir")
if err := os.Mkdir(dnam, 0777); err != nil {
t.Fatal(err)
}
sym := filepath.Join(tmpdir, "sym_dir")
if err := os.Symlink(dnam, sym); err != nil {
t.Fatal(err)
}
ff, tmpf, err := New(sym)
switch err := err.(type) {
case nil:
t.Errorf("error expected, but New succeeded (%q/%q)",
ff, tmpf.Name())
case *os.PathError:
if err.Path != sym {
t.Errorf("got *os.PathError with unexpected path "+
"(got %#v, expected %q)", err, sym)
}
default:
t.Errorf("got unexpected error type %T (%v)", err, err)
}
}
func makeTempFile(t *testing.T, srcName, expFinal string) (string, *os.File) {
tmpfileCount++
final := filepath.Join(tmpdir, srcName)
ff, tmpf, err := New(final)
if err != nil {
t.Fatalf("could not create temporary file for %q: %v",
final, err)
}
if ff != filepath.Join(tmpdir, expFinal) {
t.Fatalf("unexpected final fname %q does not match expected %q",
ff, filepath.Join(tmpdir, expFinal))
}
if filepath.Dir(tmpf.Name()) != tmpdir {
t.Fatalf("unexpected final dir %q does not match expected %q",
filepath.Dir(tmpf.Name()), tmpdir)
}
return ff, tmpf
}
func checkFile(t *testing.T, label, fname string, exp []byte) {
if raw, err := ioutil.ReadFile(fname); err != nil {
t.Errorf("%s: could not read file: %v", label, err)
} else if !bytes.Equal(raw, exp) {
t.Errorf("%s: data in %s not as expected", label, fname)
t.Errorf("%s: got %q, expected %q", label, raw, exp)
}
}

116
writefile.go Normal file
View File

@ -0,0 +1,116 @@
/*
Package writefile provides simple support routines for writing data to a temporary
file before renaming it into place. This avoids pitfalls such as writing partial
content to a file and then being interruted, or trying to write to a program that
is currently being executed, etc.
This package will correctly dereference symlinks and in the case of overwriting
will retain permissions from the original, underlying file.
*/
package writefile
import (
"errors"
"io/ioutil"
"os"
"path/filepath"
"golang.org/x/sys/unix"
)
const (
// MaxSymlinkDeref is the maximum number of symlinks that we will
// dereference before giving up.
MaxSymlinkDeref = 16
)
// New opens a file for writing. It returns the final filename which should be
// passed to Commit; this may differ from the passed target filename if the
// target is actually a symlink.
//
// Any existing file will not be altered in any way until Commit() is called.
// Data will be written to a temporary file in the same directory as the
// target.
func New(targetFname string) (finalFname string, f *os.File, err error) {
var (
info os.FileInfo
tgt string
)
finalFname = targetFname
for i := 0; i < MaxSymlinkDeref; i++ {
info, err = os.Lstat(finalFname)
switch {
case err != nil && !os.IsNotExist(err):
return
case os.IsNotExist(err), info.Mode().IsRegular():
f, err = NewNoDeref(finalFname)
return
case info.Mode()&os.ModeType == os.ModeSymlink:
tgt, err = os.Readlink(finalFname)
if err != nil {
return
}
if filepath.IsAbs(tgt) {
finalFname = tgt
} else {
finalFname = filepath.Clean(filepath.Join(filepath.Dir(finalFname), tgt))
}
default:
err = &os.PathError{
Op: "open",
Path: targetFname,
Err: errors.New("not a regular file"),
}
return
}
}
err = &os.PathError{
Op: "open",
Path: targetFname,
Err: unix.ELOOP,
}
return
}
// Abort writing to a file. Performs a Close() and unlinks the temporary file.
func Abort(f *os.File) {
_ = f.Close()
_ = os.Remove(f.Name())
}
// NewNoDeref is similar to New, but will not dereference symlinks and will
// allow them to be overwritten.
func NewNoDeref(finalFname string) (*os.File, error) {
return ioutil.TempFile(filepath.Dir(finalFname), ".new.")
}
// Commit the temporary file. This will close the file f and then rename it
// into place once it has been ensured the data is on disk. It will retain
// permissions from the original file if present.
func Commit(finalFname string, f *os.File) error {
if err := f.Sync(); err != nil {
os.Remove(f.Name())
return err
}
// if the final destination file already exists, try to inherit its
// permissions, but don't return an error if we fail
if st, err := os.Stat(finalFname); err == nil { // NB: inverted
f.Chmod(st.Mode() & os.ModePerm)
}
if err := f.Close(); err != nil {
os.Remove(f.Name())
return err
}
if err := os.Rename(f.Name(), finalFname); err != nil {
os.Remove(f.Name())
return err
}
return nil
}