// "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/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 }