--- /dev/null
+// MsgSyncReq: copy files to the remote host
+
+// 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/>.
+
+// +build !windows
+
+package sync
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "io/fs"
+ "math/rand"
+ "net/http"
+ "os"
+ "os/user"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/ianbruene/go-difflib/difflib"
+
+ "ruderich.org/simon/safcm"
+)
+
+func (s *Sync) syncFiles() error {
+ // To create random file names for symlinks
+ rand.Seed(time.Now().UnixNano())
+
+ // Sort for deterministic order and so parent directories are present
+ // when files in them are created
+ var files []*safcm.File
+ for _, x := range s.req.Files {
+ files = append(files, x)
+ }
+ sort.Slice(files, func(i, j int) bool {
+ return files[i].Path < files[j].Path
+ })
+
+ for _, x := range files {
+ var changed bool
+ err := s.syncFile(x, &changed)
+ if err != nil {
+ return fmt.Errorf("%q: %v", x.Path, err)
+ }
+ if changed {
+ s.queueTriggers(x)
+ }
+ }
+
+ return nil
+}
+
+func (s *Sync) syncFile(file *safcm.File, changed *bool) error {
+ // The general strategy is "update by rename": If any property of a
+ // file changes it will be written to a temporary file and then
+ // renamed "over" the original file. This is simple and prevents race
+ // conditions where the file is partially readable while changes to
+ // permissions or owner/group are applied. However, this strategy does
+ // not work for directories which must be removed first (was
+ // directory), must remove the existing file (will be directory) or
+ // must be directly modified (changed permissions or owner). In the
+ // first two cases the old path is removed. In the last the directory
+ // is modified (carefully) in place.
+ //
+ // The implementation is careful not to follow any symlinks to prevent
+ // possible race conditions which can be exploited and are especially
+ // dangerous when running with elevated privileges (which will most
+ // likely be the case).
+
+ err := s.fileResolveIds(file)
+ if err != nil {
+ return err
+ }
+
+ change := safcm.FileChange{
+ Path: file.Path,
+ New: safcm.FileChangeInfo{
+ Mode: file.Mode,
+ User: file.User,
+ Uid: file.Uid,
+ Group: file.Group,
+ Gid: file.Gid,
+ },
+ }
+
+ debugf := func(format string, a ...interface{}) {
+ s.log.Debugf("files: %q (%s): %s",
+ file.Path, file.OrigGroup, fmt.Sprintf(format, a...))
+ }
+ verbosef := func(format string, a ...interface{}) {
+ s.log.Verbosef("files: %q (%s): %s",
+ file.Path, file.OrigGroup, fmt.Sprintf(format, a...))
+ }
+
+ var oldStat fs.FileInfo
+reopen:
+ oldFh, err := OpenFileNoFollow(file.Path)
+ if err != nil {
+ err := err.(*fs.PathError)
+ if err.Err == syscall.ELOOP || err.Err == syscall.EMLINK {
+ // Check if ELOOP was caused not by O_NOFOLLOW but by
+ // too many nested symlinks before the final path
+ // component.
+ x, err := os.Lstat(file.Path)
+ if err != nil {
+ return err
+ }
+ if x.Mode().Type() != fs.ModeSymlink {
+ debugf("type changed from symlink to %s, retry",
+ x.Mode().Type())
+ goto reopen
+ }
+ // ELOOP from symbolic link, this is fine
+ oldStat = x
+ } else if os.IsNotExist(err) {
+ change.Created = true
+ debugf("will create")
+ } else {
+ return err
+ }
+ } else {
+ defer oldFh.Close()
+
+ x, err := oldFh.Stat()
+ if err != nil {
+ return err
+ }
+ oldStat = x
+ }
+
+ var oldData []byte
+ var changeType, changePerm, changeUserOrGroup, changeData bool
+ if !change.Created {
+ // Compare permissions
+ change.Old.Mode = oldStat.Mode()
+ if change.Old.Mode.Type() == fs.ModeSymlink {
+ // Some BSD systems permit changing permissions of
+ // symlinks but ignore them on traversal. To keep it
+ // simple we don't support that and always use 0777
+ // for symlink permissions (the value on GNU/Linux).
+ //
+ // TODO: Add proper support for symlinks on BSD
+ change.Old.Mode |= 0777
+ }
+ if change.Old.Mode != file.Mode {
+ if change.Old.Mode.Type() != file.Mode.Type() {
+ changeType = true
+ debugf("type differs %s -> %s",
+ change.Old.Mode.Type(),
+ file.Mode.Type())
+ } else {
+ // Be careful with .Perm() which does not
+ // contain the setuid/setgid/sticky bits!
+ changePerm = true
+ debugf("permission differs %s -> %s",
+ change.Old.Mode, file.Mode)
+ }
+ }
+
+ // Compare user/group
+ x, ok := oldStat.Sys().(*syscall.Stat_t)
+ if !ok {
+ return fmt.Errorf("unsupported Stat().Sys()")
+ }
+ change.Old.Uid = int(x.Uid)
+ change.Old.Gid = int(x.Gid)
+ if change.Old.Uid != file.Uid || change.Old.Gid != file.Gid {
+ changeUserOrGroup = true
+ debugf("uid/gid differs %d/%d -> %d/%d",
+ change.Old.Uid, change.Old.Gid,
+ file.Uid, file.Gid)
+ }
+ u, g, err := resolveIdsToNames(change.Old.Uid, change.Old.Gid)
+ // Errors are not relevant as this is only used to report the
+ // change. If the user/group no longer exits only the ids will
+ // be reported.
+ if err == nil {
+ change.Old.User = u
+ change.Old.Group = g
+ }
+
+ // Compare file content (if possible)
+ switch change.Old.Mode.Type() {
+ case 0: // regular file
+ x, err := io.ReadAll(oldFh)
+ if err != nil {
+ return fmt.Errorf("reading old content: %v",
+ err)
+ }
+ oldData = x
+ case fs.ModeSymlink:
+ x, err := os.Readlink(file.Path)
+ if err != nil {
+ return fmt.Errorf("reading old content: %v",
+ err)
+ }
+ oldData = []byte(x)
+ }
+ if !changeType && file.Mode.Type() != fs.ModeDir {
+ if !bytes.Equal(oldData, file.Data) {
+ changeData = true
+ debugf("content differs")
+ }
+ }
+ }
+ oldStat = nil // prevent accidental use
+
+ // No changes
+ if !change.Created && !changeType &&
+ !changePerm && !changeUserOrGroup &&
+ !changeData {
+ debugf("unchanged")
+ return nil
+ }
+ *changed = true
+
+ // Don't show a diff with the full content for newly created files or
+ // on type changes. This is just noise for the user as the new file
+ // content is obvious. But we always want to see a diff when files are
+ // replaced because this destroys data.
+ if !change.Created &&
+ (change.Old.Mode.Type() == 0 ||
+ change.Old.Mode.Type() == fs.ModeSymlink) {
+ change.DataDiff, err = diffData(oldData, file.Data)
+ if err != nil {
+ return err
+ }
+ }
+
+ // Add change here so it is stored even when applying it fails. This
+ // way the user knows exactly what was attempted.
+ s.resp.FileChanges = append(s.resp.FileChanges, change)
+
+ if change.Created {
+ verbosef("creating")
+ } else {
+ verbosef("updating")
+ }
+
+ if s.req.DryRun {
+ debugf("dry-run, skipping changes")
+ return nil
+ }
+
+ // We cannot rename over directories and vice versa
+ if changeType && (change.Old.Mode.IsDir() || file.Mode.IsDir()) {
+ debugf("removing (due to type change)")
+ err := os.RemoveAll(file.Path)
+ if err != nil {
+ return err
+ }
+ }
+
+ // Directory: create new directory, also type change to directory
+ if file.Mode.IsDir() && (change.Created || changeType) {
+ debugf("creating directory")
+ err := os.Mkdir(file.Path, 0700)
+ if err != nil {
+ return err
+ }
+ // We must be careful not to chmod arbitrary files. If the
+ // target directory is writable then it might have changed to
+ // a symlink at this point. There's no lchmod so open the
+ // directory.
+ debugf("chmodding %s", file.Mode)
+ dh, err := OpenFileNoFollow(file.Path)
+ if err != nil {
+ return err
+ }
+ err = dh.Chmod(file.Mode)
+ if err != nil {
+ dh.Close()
+ return err
+ }
+ // Less restrictive access is not relevant here because there
+ // are no files present yet.
+ debugf("chowning %d/%d", file.Uid, file.Gid)
+ err = dh.Chown(file.Uid, file.Gid)
+ if err != nil {
+ dh.Close()
+ return err
+ }
+ dh.Close()
+ return nil
+ }
+ // Directory: changed permission or user/group
+ if file.Mode.IsDir() {
+ // We don't know if the new permission or if the new
+ // user/group is more restrictive (e.g. root:root 0750 ->
+ // user:group 0700; applying group first gives group
+ // unexpected access). To prevent a short window where the
+ // access might be too lax we temporarily deny all access.
+ if changePerm && changeUserOrGroup {
+ // Only drop group and other permission because user
+ // has access anyway (either before or after the
+ // change). This also prevents temporary errors during
+ // the error when the user tries to access this
+ // directory (access for the group will fail though).
+ mode := change.Old.Mode & fs.ModePerm & 0700
+ debugf("chmodding %#o (temporary)", mode)
+ err := oldFh.Chmod(mode)
+ if err != nil {
+ return err
+ }
+ }
+ if changeUserOrGroup {
+ debugf("chowning %d/%d", file.Uid, file.Gid)
+ err := oldFh.Chown(file.Uid, file.Gid)
+ if err != nil {
+ return err
+ }
+ }
+ if changePerm {
+ debugf("chmodding %s", file.Mode)
+ err := oldFh.Chmod(file.Mode)
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+ }
+
+ dir := filepath.Dir(file.Path)
+ // Create hidden file which should be ignored by most other tools and
+ // thus not affect anything during creation
+ base := "." + filepath.Base(file.Path)
+
+ var tmpPath string
+ switch file.Mode.Type() {
+ case 0: // regular file
+ debugf("creating temporary file %q",
+ filepath.Join(dir, base+"*"))
+ tmpPath, err = WriteTemp(dir, base, file.Data,
+ file.Uid, file.Gid, file.Mode)
+ if err != nil {
+ return err
+ }
+
+ case fs.ModeSymlink:
+ i := 0
+ retry:
+ // Similar to os.CreateTemp() but for symlinks which we cannot
+ // open as file
+ tmpPath = filepath.Join(dir,
+ base+strconv.Itoa(rand.Int()))
+ debugf("creating temporary symlink %q", tmpPath)
+ err := os.Symlink(string(file.Data), tmpPath)
+ if err != nil {
+ if os.IsExist(err) && i < 10000 {
+ i++
+ goto retry
+ }
+ return err
+ }
+ err = os.Lchown(tmpPath, file.Uid, file.Gid)
+ if err != nil {
+ os.Remove(tmpPath)
+ return err
+ }
+ // Permissions are irrelevant for symlinks (on most systems)
+
+ default:
+ panic(fmt.Sprintf("invalid file type %s", file.Mode))
+ }
+
+ debugf("renaming %q", tmpPath)
+ err = os.Rename(tmpPath, file.Path)
+ if err != nil {
+ os.Remove(tmpPath)
+ return err
+ }
+ err = SyncPath(dir)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (s *Sync) fileResolveIds(file *safcm.File) error {
+ if file.User != "" && file.Uid != -1 {
+ return fmt.Errorf("cannot set both User (%q) and Uid (%d)",
+ file.User, file.Uid)
+ }
+ if file.Group != "" && file.Gid != -1 {
+ return fmt.Errorf("cannot set both Group (%q) and Gid (%d)",
+ file.Group, file.Gid)
+ }
+
+ if file.User == "" && file.Uid == -1 {
+ file.User = s.defaultUser
+ }
+ if file.User != "" {
+ x, err := user.Lookup(file.User)
+ if err != nil {
+ return err
+ }
+ id, err := strconv.Atoi(x.Uid)
+ if err != nil {
+ return err
+ }
+ file.Uid = id
+ }
+
+ if file.Group == "" && file.Gid == -1 {
+ file.Group = s.defaultGroup
+ }
+ if file.Group != "" {
+ x, err := user.LookupGroup(file.Group)
+ if err != nil {
+ return err
+ }
+ id, err := strconv.Atoi(x.Gid)
+ if err != nil {
+ return err
+ }
+ file.Gid = id
+ }
+
+ return nil
+}
+
+func resolveIdsToNames(uid, gid int) (string, string, error) {
+ u, err := user.LookupId(strconv.Itoa(uid))
+ if err != nil {
+ return "", "", err
+ }
+ g, err := user.LookupGroupId(strconv.Itoa(gid))
+ if err != nil {
+ return "", "", err
+ }
+ return u.Username, g.Name, nil
+}
+
+func diffData(oldData []byte, newData []byte) (string, error) {
+ oldBin := !strings.HasPrefix(http.DetectContentType(oldData), "text/")
+ newBin := !strings.HasPrefix(http.DetectContentType(newData), "text/")
+ if oldBin && newBin {
+ return fmt.Sprintf("Binary files differ (%d -> %d bytes), "+
+ "cannot show diff", len(oldData), len(newData)), nil
+ }
+ if oldBin {
+ oldData = []byte(fmt.Sprintf("<binary content, %d bytes>\n",
+ len(oldData)))
+ }
+ if newBin {
+ newData = []byte(fmt.Sprintf("<binary content, %d bytes>\n",
+ len(newData)))
+ }
+
+ // TODO: difflib shows empty context lines at the end of the file
+ // which should not be there
+ // TODO: difflib has issues with missing newlines in either side
+ result, err := difflib.GetUnifiedDiffString(difflib.LineDiffParams{
+ A: difflib.SplitLines(string(oldData)),
+ B: difflib.SplitLines(string(newData)),
+ Context: 3,
+ })
+ if err != nil {
+ return "", err
+ }
+ return result, nil
+}
+
+func OpenFileNoFollow(path string) (*os.File, error) {
+ return os.OpenFile(path,
+ // O_NOFOLLOW prevents symlink attacks
+ // O_NONBLOCK is necessary to prevent blocking on FIFOs
+ os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
+}
+
+func WriteTemp(dir, base string, data []byte, uid, gid int, mode fs.FileMode) (
+ string, error) {
+
+ fh, err := os.CreateTemp(dir, base)
+ if err != nil {
+ return "", err
+ }
+ tmpPath := fh.Name()
+
+ _, err = fh.Write(data)
+ if err != nil {
+ fh.Close()
+ os.Remove(tmpPath)
+ return "", err
+ }
+ // CreateTemp() creates the file with 0600
+ err = fh.Chown(uid, gid)
+ if err != nil {
+ fh.Close()
+ os.Remove(tmpPath)
+ return "", err
+ }
+ err = fh.Chmod(mode)
+ if err != nil {
+ fh.Close()
+ os.Remove(tmpPath)
+ return "", err
+ }
+ err = fh.Sync()
+ if err != nil {
+ fh.Close()
+ os.Remove(tmpPath)
+ return "", err
+ }
+ err = fh.Close()
+ if err != nil {
+ fh.Close()
+ os.Remove(tmpPath)
+ return "", err
+ }
+
+ return tmpPath, nil
+}
+
+// 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.
+//
+// Calling sync on the files itself is not enough according to POSIX; man 2
+// 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 {
+ x, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ err = x.Sync()
+ closeErr := x.Close()
+ if err != nil {
+ return err
+ }
+ return closeErr
+}