]> ruderich.org/simon Gitweb - safcm/safcm.git/blobdiff - cmd/safcm-remote/sync/files.go
Move implementation of cmd/safcm-remote/ to remote/
[safcm/safcm.git] / cmd / safcm-remote / sync / files.go
diff --git a/cmd/safcm-remote/sync/files.go b/cmd/safcm-remote/sync/files.go
deleted file mode 100644 (file)
index e0a2221..0000000
+++ /dev/null
@@ -1,553 +0,0 @@
-// 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
-}