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