// "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 // SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2021-2024 Simon Ruderich package ainsl import ( "bytes" "flag" "fmt" "io" "io/fs" "os" "strings" "syscall" "golang.org/x/sys/unix" "ruderich.org/simon/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:]) //nolint:errcheck 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) } parentFd, baseName, err := sync.OpenParentDirectoryNoSymlinks(path) if err != nil { return nil, err } defer unix.Close(parentFd) var changes []string var uid, gid int var mode fs.FileMode data, stat, err := readFileAtNoFollow(parentFd, baseName) if err != nil { if !os.IsNotExist(err) { return nil, fmt.Errorf("%q: %v", path, 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 //nolint:wastedassign // 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 tmpBase, err := sync.WriteTempAt(parentFd, "."+baseName, data, uid, gid, mode) if err != nil { return nil, err } err = unix.Renameat(parentFd, tmpBase, parentFd, baseName) if err != nil { unix.Unlinkat(parentFd, tmpBase, 0 /* flags */) //nolint:errcheck return nil, err } err = unix.Fsync(parentFd) if err != nil { return nil, err } return changes, nil } func readFileAtNoFollow(dirfd int, base string) ([]byte, fs.FileInfo, error) { fh, err := sync.OpenAtNoFollow(dirfd, base) 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("not a regular file but %s", stat.Mode().Type()) } x, err := io.ReadAll(fh) if err != nil { return nil, nil, err } return x, stat, nil }