commit 61c1049e283a2b35b1e1ce90c8b6df0d71886f1e Author: Laurence Withers Date: Thu Jan 2 09:47:21 2020 +0000 Initial commit after importing from github diff --git a/README.md b/README.md new file mode 100644 index 0000000..60e5a36 --- /dev/null +++ b/README.md @@ -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. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..dcc40e7 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module src.lwithers.me.uk/go/writefile + +go 1.13 + +require golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0cab35b --- /dev/null +++ b/go.sum @@ -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= diff --git a/unit_test.go b/unit_test.go new file mode 100644 index 0000000..9fc1d88 --- /dev/null +++ b/unit_test.go @@ -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) + } +} diff --git a/writefile.go b/writefile.go new file mode 100644 index 0000000..62b8a56 --- /dev/null +++ b/writefile.go @@ -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 +}