From: Simon Ruderich Date: Sun, 4 Apr 2021 21:35:50 +0000 (+0200) Subject: remote: add ainsl sub-command ("append if no such line") X-Git-Url: https://ruderich.org/simon/gitweb/?a=commitdiff_plain;h=ddd21f01f764a4ff61204d8e9d0ef8421ebf685c;hp=bc439f5c4aabef1cccfe40233c2e467602a55a60;p=safcm%2Fsafcm.git remote: add ainsl sub-command ("append if no such line") 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. --- diff --git a/cmd/safcm-remote/ainsl/ainsl.go b/cmd/safcm-remote/ainsl/ainsl.go new file mode 100644 index 0000000..f03ecc8 --- /dev/null +++ b/cmd/safcm-remote/ainsl/ainsl.go @@ -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 . + +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 [] \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 index 0000000..25258ff --- /dev/null +++ b/cmd/safcm-remote/ainsl/ainsl_test.go @@ -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 . + +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) + } + } +} diff --git a/cmd/safcm-remote/main.go b/cmd/safcm-remote/main.go index 46a651a..9c58d3a 100644 --- a/cmd/safcm-remote/main.go +++ b/cmd/safcm-remote/main.go @@ -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] ", + os.Args[0]) } - err := mainLoop() if err != nil { log.Fatalf("%s: %v", os.Args[0], err) } diff --git a/cmd/safcm-remote/sync/files.go b/cmd/safcm-remote/sync/files.go index 6e001a8..3e3c7ec 100644 --- a/cmd/safcm-remote/sync/files.go +++ b/cmd/safcm-remote/sync/files.go @@ -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