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