--- /dev/null
+// "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
+}
--- /dev/null
+// 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)
+ }
+ }
+}
"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"
// 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)
}
os.Remove(tmpPath)
return err
}
- err = syncPath(dir)
+ err = SyncPath(dir)
if err != nil {
return err
}
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.
//
// 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