]> ruderich.org/simon Gitweb - safcm/safcm.git/commitdiff
remote: add ainsl sub-command ("append if no such line")
authorSimon Ruderich <simon@ruderich.org>
Sun, 4 Apr 2021 21:35:50 +0000 (23:35 +0200)
committerSimon Ruderich <simon@ruderich.org>
Mon, 5 Apr 2021 07:41:12 +0000 (09:41 +0200)
It is preferred to deploy complete files by putting them in the files/
directory of a group. However, sometimes this is not possible because
parts of the file's content are unknown or managed by other programs or
users. An example is .ssh/authorized_keys which should contain certain
keys but which is also managed manually. `ainsl` permits adding a key to
the file without rewriting it completely.

`ainsl` can be used by specifying the following command:

    $SAFCM_HELPER ainsl /path/to/file line-to-add

Per default non-existent files are an error. To create the file if
necessary use:

    $SAFCM_HELPER ainsl -create /path/to/file line-to-add

The environment variable $SAFCM_HELPER is set when executing commands
and contains the absolute path to the remote helper.

cmd/safcm-remote/ainsl/ainsl.go [new file with mode: 0644]
cmd/safcm-remote/ainsl/ainsl_test.go [new file with mode: 0644]
cmd/safcm-remote/main.go
cmd/safcm-remote/sync/files.go

diff --git a/cmd/safcm-remote/ainsl/ainsl.go b/cmd/safcm-remote/ainsl/ainsl.go
new file mode 100644 (file)
index 0000000..f03ecc8
--- /dev/null
@@ -0,0 +1,191 @@
+// "ainsl" sub-command: "append if no such line" (inspired by FAI's ainsl),
+// append lines to files (if not present) without replacing the file
+// completely
+//
+// FAI: https://fai-project.org
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package ainsl
+
+import (
+       "bytes"
+       "flag"
+       "fmt"
+       "io"
+       "io/fs"
+       "os"
+       "path/filepath"
+       "strings"
+       "syscall"
+
+       "ruderich.org/simon/safcm/cmd/safcm-remote/sync"
+)
+
+func Main(args []string) error {
+       flag.Usage = func() {
+               fmt.Fprintf(os.Stderr,
+                       "usage: %s ainsl [<options>] <path> <line>\n",
+                       args[0])
+               flag.PrintDefaults()
+       }
+
+       optionCreate := flag.Bool("create", false,
+               "create the path if it does not exist")
+
+       flag.CommandLine.Parse(args[2:])
+
+       if flag.NArg() != 2 {
+               flag.Usage()
+               os.Exit(1)
+       }
+
+       changes, err := handle(flag.Args()[0], flag.Args()[1], *optionCreate)
+       if err != nil {
+               return fmt.Errorf("ainsl: %v", err)
+       }
+       for _, x := range changes {
+               fmt.Fprintln(os.Stderr, x)
+       }
+       return nil
+}
+
+func handle(path string, line string, create bool) ([]string, error) {
+       // See safcm-remote/sync/files.go for details on the implementation
+
+       if line == "" {
+               return nil, fmt.Errorf("empty line")
+       }
+       if strings.Contains(line, "\n") {
+               return nil, fmt.Errorf("line must not contain newlines: %q",
+                       line)
+       }
+
+       var changes []string
+
+       var uid, gid int
+       var mode fs.FileMode
+       data, stat, err := readFileNoFollow(path)
+       if err != nil {
+               if !os.IsNotExist(err) {
+                       return nil, err
+               }
+               if !create {
+                       return nil, fmt.Errorf(
+                               "%q: file does not exist, use -create",
+                               path)
+               }
+
+               uid, gid = os.Getuid(), os.Getgid()
+               // Read current umask. Don't do this in programs where
+               // multiple goroutines create files because this is inherently
+               // racy! No goroutines here, so it's fine.
+               umask := syscall.Umask(0)
+               syscall.Umask(umask)
+               // Apply umask to created file
+               mode = 0666 & ^fs.FileMode(umask)
+
+               changes = append(changes,
+                       fmt.Sprintf("%q: created file (%d/%d %s)",
+                               path, uid, gid, mode))
+
+       } else {
+               // Preserve user/group and mode of existing file
+               x, ok := stat.Sys().(*syscall.Stat_t)
+               if !ok {
+                       return nil, fmt.Errorf("unsupported Stat().Sys()")
+               }
+               uid = int(x.Uid)
+               gid = int(x.Gid)
+               mode = stat.Mode()
+       }
+       stat = nil // prevent accidental use
+
+       // Check if the expected line is present
+       var found bool
+       for _, x := range bytes.Split(data, []byte("\n")) {
+               if string(x) == line {
+                       found = true
+                       break
+               }
+       }
+       // Make sure the file has a trailing newline. This enforces symmetry
+       // with our changes. Whenever we add a line we also append a trailing
+       // newline. When we conclude that no changes are necessary the file
+       // should be in the same state as we would leave it if there were
+       // changes.
+       if len(data) != 0 && data[len(data)-1] != '\n' {
+               data = append(data, '\n')
+               changes = append(changes,
+                       fmt.Sprintf("%q: added missing trailing newline",
+                               path))
+       }
+
+       // Line present, nothing to do
+       if found && len(changes) == 0 {
+               return nil, nil
+       }
+
+       // Append line
+       if !found {
+               data = append(data, []byte(line+"\n")...)
+               changes = append(changes,
+                       fmt.Sprintf("%q: added line %q", path, line))
+       }
+
+       // Write via temporary file and rename
+       dir := filepath.Dir(path)
+       base := filepath.Base(path)
+       tmpPath, err := sync.WriteTemp(dir, "."+base, data, uid, gid, mode)
+       if err != nil {
+               return nil, err
+       }
+       err = os.Rename(tmpPath, path)
+       if err != nil {
+               os.Remove(tmpPath)
+               return nil, err
+       }
+       err = sync.SyncPath(dir)
+       if err != nil {
+               return nil, err
+       }
+
+       return changes, nil
+}
+
+func readFileNoFollow(path string) ([]byte, fs.FileInfo, error) {
+       fh, err := sync.OpenFileNoFollow(path)
+       if err != nil {
+               return nil, nil, err
+       }
+       defer fh.Close()
+
+       stat, err := fh.Stat()
+       if err != nil {
+               return nil, nil, err
+       }
+       if stat.Mode().Type() != 0 /* regular file */ {
+               return nil, nil, fmt.Errorf("%q is not a regular file but %s",
+                       path, stat.Mode().Type())
+       }
+
+       x, err := io.ReadAll(fh)
+       if err != nil {
+               return nil, nil, fmt.Errorf("%q: %v", path, err)
+       }
+
+       return x, stat, nil
+}
diff --git a/cmd/safcm-remote/ainsl/ainsl_test.go b/cmd/safcm-remote/ainsl/ainsl_test.go
new file mode 100644 (file)
index 0000000..25258ff
--- /dev/null
@@ -0,0 +1,338 @@
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package ainsl
+
+import (
+       "fmt"
+       "io/fs"
+       "os"
+       "path/filepath"
+       "reflect"
+       "syscall"
+       "testing"
+
+       "github.com/google/go-cmp/cmp"
+
+       ft "ruderich.org/simon/safcm/cmd/safcm-remote/sync/filetest"
+)
+
+func TestHandle(t *testing.T) {
+       cwd, err := os.Getwd()
+       if err != nil {
+               t.Fatal(err)
+       }
+       defer os.Chdir(cwd)
+
+       err = os.RemoveAll("testdata")
+       if err != nil {
+               t.Fatal(err)
+       }
+       err = os.Mkdir("testdata", 0700)
+       if err != nil {
+               t.Fatal(err)
+       }
+
+       // Set umask to test mode for new files
+       umask := syscall.Umask(027)
+       defer syscall.Umask(umask)
+
+       root := ft.File{
+               Path: ".",
+               Mode: fs.ModeDir | 0700,
+       }
+       _, uid, _, gid := ft.CurrentUserAndGroup()
+
+       tests := []struct {
+               name       string
+               path       string
+               line       string
+               create     bool
+               prepare    func()
+               expFiles   []ft.File
+               expChanges []string
+               expErr     error
+       }{
+
+               // TODO: Add tests for chown and run them only as root
+
+               {
+                       "missing",
+                       "file",
+                       "line",
+                       false,
+                       nil,
+                       []ft.File{
+                               root,
+                       },
+                       nil,
+                       fmt.Errorf("\"file\": file does not exist, use -create"),
+               },
+
+               {
+                       "missing: create",
+                       "file",
+                       "line",
+                       true,
+                       nil,
+                       []ft.File{
+                               root,
+                               ft.File{
+                                       Path: "file",
+                                       Mode: 0640,
+                                       Data: []byte("line\n"),
+                               },
+                       },
+                       []string{
+                               fmt.Sprintf(`"file": created file (%d/%d -rw-r-----)`, uid, gid),
+                               `"file": added line "line"`,
+                       },
+                       nil,
+               },
+               {
+                       "missing: create (empty line)",
+                       "file",
+                       "",
+                       true,
+                       nil,
+                       []ft.File{
+                               root,
+                       },
+                       nil,
+                       fmt.Errorf("empty line"),
+               },
+               {
+                       "missing: create (newline)",
+                       "file",
+                       "line\n",
+                       true,
+                       nil,
+                       []ft.File{
+                               root,
+                       },
+                       nil,
+                       fmt.Errorf("line must not contain newlines: \"line\\n\""),
+               },
+
+               {
+                       "exists: unchanged",
+                       "file",
+                       "line",
+                       true,
+                       func() {
+                               ft.CreateFile("file", "line\n", 0641)
+                       },
+                       []ft.File{
+                               root,
+                               ft.File{
+                                       Path: "file",
+                                       Mode: 0641,
+                                       Data: []byte("line\n"),
+                               },
+                       },
+                       nil,
+                       nil,
+               },
+
+               {
+                       "exists: append",
+                       "file",
+                       "line",
+                       true,
+                       func() {
+                               ft.CreateFile("file", "existing\n", 0641)
+                       },
+                       []ft.File{
+                               root,
+                               ft.File{
+                                       Path: "file",
+                                       Mode: 0641,
+                                       Data: []byte("existing\nline\n"),
+                               },
+                       },
+                       []string{
+                               `"file": added line "line"`,
+                       },
+                       nil,
+               },
+               {
+                       "exists: append (no newline in file)",
+                       "file",
+                       "line",
+                       true,
+                       func() {
+                               ft.CreateFile("file", "existing", 0641)
+                       },
+                       []ft.File{
+                               root,
+                               ft.File{
+                                       Path: "file",
+                                       Mode: 0641,
+                                       Data: []byte("existing\nline\n"),
+                               },
+                       },
+                       []string{
+                               `"file": added missing trailing newline`,
+                               `"file": added line "line"`,
+                       },
+                       nil,
+               },
+               {
+                       "exists: append (only trailing newline I)",
+                       "file",
+                       "line",
+                       true,
+                       func() {
+                               ft.CreateFile("file", "first\nline", 0641)
+                       },
+                       []ft.File{
+                               root,
+                               ft.File{
+                                       Path: "file",
+                                       Mode: 0641,
+                                       Data: []byte("first\nline\n"),
+                               },
+                       },
+                       []string{
+                               `"file": added missing trailing newline`,
+                       },
+                       nil,
+               },
+               {
+                       "exists: append (only trailing newline II)",
+                       "file",
+                       "first",
+                       true,
+                       func() {
+                               ft.CreateFile("file", "first\nline", 0641)
+                       },
+                       []ft.File{
+                               root,
+                               ft.File{
+                                       Path: "file",
+                                       Mode: 0641,
+                                       Data: []byte("first\nline\n"),
+                               },
+                       },
+                       []string{
+                               `"file": added missing trailing newline`,
+                       },
+                       nil,
+               },
+               {
+                       "exists: append (partial line)",
+                       "file",
+                       "line with spaces",
+                       true,
+                       func() {
+                               ft.CreateFile("file", "# line with spaces\n", 0641)
+                       },
+                       []ft.File{
+                               root,
+                               ft.File{
+                                       Path: "file",
+                                       Mode: 0641,
+                                       Data: []byte("# line with spaces\nline with spaces\n"),
+                               },
+                       },
+                       []string{
+                               `"file": added line "line with spaces"`,
+                       },
+                       nil,
+               },
+
+               {
+                       "exists: symlink",
+                       "file",
+                       "line",
+                       true,
+                       func() {
+                               ft.CreateSymlink("file", "target")
+                       },
+                       []ft.File{
+                               root,
+                               ft.File{
+                                       Path: "file",
+                                       Mode: fs.ModeSymlink | 0777,
+                                       Data: []byte("target"),
+                               },
+                       },
+                       nil,
+                       fmt.Errorf("open file: too many levels of symbolic links"),
+               },
+               {
+                       "exists: fifo",
+                       "file",
+                       "line",
+                       true,
+                       func() {
+                               ft.CreateFifo("file", 0640)
+                       },
+                       []ft.File{
+                               root,
+                               ft.File{
+                                       Path: "file",
+                                       Mode: fs.ModeNamedPipe | 0640,
+                               },
+                       },
+                       nil,
+                       fmt.Errorf("\"file\" is not a regular file but p---------"),
+               },
+       }
+
+       for _, tc := range tests {
+               // Create separate test directory for each test case
+               path := filepath.Join(cwd, "testdata", tc.name)
+               err = os.Mkdir(path, 0700)
+               if err != nil {
+                       t.Fatal(err)
+               }
+               err = os.Chdir(path)
+               if err != nil {
+                       t.Fatal(err)
+               }
+
+               if tc.prepare != nil {
+                       tc.prepare()
+               }
+
+               changes, err := handle(tc.path, tc.line, tc.create)
+               if !reflect.DeepEqual(tc.expChanges, changes) {
+                       t.Errorf("%s: changes: %s", tc.name,
+                               cmp.Diff(tc.expChanges, changes))
+               }
+               // Ugly but the simplest way to compare errors (including nil)
+               if fmt.Sprintf("%s", err) != fmt.Sprintf("%s", tc.expErr) {
+                       t.Errorf("%s: err = %#v, want %#v",
+                               tc.name, err, tc.expErr)
+               }
+
+               files, err := ft.WalkDir(path)
+               if err != nil {
+                       t.Fatal(err)
+               }
+               if !reflect.DeepEqual(tc.expFiles, files) {
+                       t.Errorf("%s: files: %s", tc.name,
+                               cmp.Diff(tc.expFiles, files))
+               }
+       }
+
+       if !t.Failed() {
+               err = os.RemoveAll(filepath.Join(cwd, "testdata"))
+               if err != nil {
+                       t.Fatal(err)
+               }
+       }
+}
index 46a651aed947f96a3e136639da8f79b8ff366719..9c58d3af8175c337aa60a2b47bc1383105cc0226 100644 (file)
@@ -23,6 +23,7 @@ import (
        "os"
 
        "ruderich.org/simon/safcm"
+       "ruderich.org/simon/safcm/cmd/safcm-remote/ainsl"
        "ruderich.org/simon/safcm/cmd/safcm-remote/info"
        "ruderich.org/simon/safcm/cmd/safcm-remote/run"
        "ruderich.org/simon/safcm/cmd/safcm-remote/sync"
@@ -32,11 +33,17 @@ func main() {
        // Timestamps are added by `safcm`
        log.SetFlags(0)
 
-       if len(os.Args) != 1 {
-               log.Fatalf("usage: %s", os.Args[0])
+       var err error
+       if len(os.Args) == 1 {
+               err = mainLoop()
+       } else if len(os.Args) >= 2 && os.Args[1] == "ainsl" {
+               err = ainsl.Main(os.Args)
+       } else {
+               log.Fatalf("usage: %[1]s\n"+
+                       "usage: %[1]s ainsl [options] <path> <line>",
+                       os.Args[0])
        }
 
-       err := mainLoop()
        if err != nil {
                log.Fatalf("%s: %v", os.Args[0], err)
        }
index 6e001a8186d14675864b4d90794d77694269cee0..3e3c7ec18357e8a01e21e22906e6a5e79cb8257f 100644 (file)
@@ -376,7 +376,7 @@ reopen:
                os.Remove(tmpPath)
                return err
        }
-       err = syncPath(dir)
+       err = SyncPath(dir)
        if err != nil {
                return err
        }
@@ -517,7 +517,7 @@ func WriteTemp(dir, base string, data []byte, uid, gid int, mode fs.FileMode) (
        return tmpPath, nil
 }
 
-// syncPath syncs path, which should be a directory. To guarantee durability
+// SyncPath syncs path, which should be a directory. To guarantee durability
 // it must be called on a parent directory after adding, renaming or removing
 // files therein.
 //
@@ -525,7 +525,7 @@ func WriteTemp(dir, base string, data []byte, uid, gid int, mode fs.FileMode) (
 // fsync: "Calling fsync() does not necessarily ensure that the entry in the
 // directory containing the file has also reached disk. For that an explicit
 // fsync() on a file descriptor for the directory is also needed."
-func syncPath(path string) error {
+func SyncPath(path string) error {
        x, err := os.Open(path)
        if err != nil {
                return err