// MsgSyncReq: copy files to the remote host
// Copyright (C) 2021-2023 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 .
//go:build !windows
// +build !windows
package sync
import (
"bytes"
"fmt"
"io"
"io/fs"
"math/rand"
"net/http"
"os"
"os/user"
slashpath "path"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/ianbruene/go-difflib/difflib"
"golang.org/x/sys/unix"
"ruderich.org/simon/safcm"
)
// NOTE: Don't use plain os.Open, os.OpenFile, os.Remove or any other function
// which uses absolute paths. These are vulnerable to symlink attacks. Always
// use *at syscalls. See below for details.
// openReadonlyFlags are flags for open* syscalls to safely read a file or
// directory.
//
// O_NOFOLLOW prevents symlink attacks in the last path component
// O_NONBLOCK is necessary to prevent blocking on FIFOs
const openReadonlyFlags = unix.O_RDONLY | unix.O_NOFOLLOW | unix.O_NONBLOCK
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 the new version 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). This includes not using absolute paths in
// syscalls to prevent symlink attacks when a directory is writable by
// other users (e.g. when syncing a file to /home/user/dir/file the
// user could create dir as symlink to another directory and file
// would be written there). To prevent this *at syscalls are used and
// all symlinks in the path are rejected. This still permits the user
// to move dir during the sync but only to places which are writable
// by the user which cannot be prevented.
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...))
}
parentFd, baseName, err := OpenParentDirectoryNoSymlinks(file.Path)
if err != nil {
if os.IsNotExist(err) && s.req.DryRun {
change.Created = true
debugf("will create (parent missing)")
*changed = true
debugf("dry-run, skipping changes")
s.resp.FileChanges = append(s.resp.FileChanges, change)
return nil
}
return err
}
defer unix.Close(parentFd)
var oldStat unix.Stat_t
reopen:
oldFh, err := OpenAtNoFollow(parentFd, baseName)
if err != nil {
if err == unix.ELOOP || err == unix.EMLINK {
// ELOOP from symbolic link, this is fine
err := unix.Fstatat(parentFd, baseName, &oldStat,
unix.AT_SYMLINK_NOFOLLOW)
if err != nil {
return err
}
if oldStat.Mode&unix.S_IFMT != unix.S_IFLNK {
debugf("type changed from symlink, retrying")
goto reopen
}
} else if os.IsNotExist(err) {
change.Created = true
debugf("will create")
} else {
return err
}
} else {
defer oldFh.Close()
err := unix.Fstat(int(oldFh.Fd()), &oldStat)
if err != nil {
return err
}
}
var oldData []byte
var changeType, changePerm, changeUserOrGroup, changeData bool
if !change.Created {
// Manually convert to FileMode; from src/os/stat_linux.go in
// Go's sources (stat_*.go for other UNIX systems are
// identical, except for stat_darwin.go which has an extra
// S_IFWHT)
mode := fs.FileMode(oldStat.Mode & 0777)
switch oldStat.Mode & unix.S_IFMT {
case unix.S_IFBLK:
mode |= fs.ModeDevice
case unix.S_IFCHR:
mode |= fs.ModeDevice | fs.ModeCharDevice
case unix.S_IFDIR:
mode |= fs.ModeDir
case unix.S_IFIFO:
mode |= fs.ModeNamedPipe
case unix.S_IFLNK:
mode |= fs.ModeSymlink
case unix.S_IFREG:
// nothing to do
case unix.S_IFSOCK:
mode |= fs.ModeSocket
// Guard against unknown file types (e.g. on darwin); not in
// stat_*.go
default:
return fmt.Errorf("unexpected file mode %v",
oldStat.Mode&unix.S_IFMT)
}
if oldStat.Mode&unix.S_ISGID != 0 {
mode |= fs.ModeSetgid
}
if oldStat.Mode&unix.S_ISUID != 0 {
mode |= fs.ModeSetuid
}
if oldStat.Mode&unix.S_ISVTX != 0 {
mode |= fs.ModeSticky
}
// Compare permissions
change.Old.Mode = 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)
// when comparing. The actual permissions on the file
// system might be different on BSD systems.
//
// 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
change.Old.Uid = int(oldStat.Uid)
change.Old.Gid = int(oldStat.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() { //nolint:exhaustive
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:
buf := make([]byte, unix.PathMax)
n, err := unix.Readlinkat(parentFd, baseName, buf)
if err != nil {
return fmt.Errorf("reading old content: %v",
err)
}
oldData = buf[:n]
}
if !changeType && file.Mode.Type() != fs.ModeDir {
if !bytes.Equal(oldData, file.Data) {
changeData = true
debugf("content differs")
}
}
}
// 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)")
// In the past os.RemoveAll() was used here. However, this is
// difficult to implement manually with *at syscalls. To keep
// it simple only permit removing files and empty directories
// here. This also has the bonus of preventing data loss when
// (accidentally) replacing a directory tree with a file.
const msg = "will not replace non-empty directory, " +
"please remove manually"
err := unix.Unlinkat(parentFd, baseName, 0 /* flags */)
if err != nil && !os.IsNotExist(err) {
err2 := unix.Unlinkat(parentFd, baseName,
unix.AT_REMOVEDIR)
if err2 != nil && !os.IsNotExist(err2) {
// See src/os/file_unix.go in Go's sources
if err2 == unix.ENOTDIR {
return err
} else if err2 == unix.ENOTEMPTY {
return fmt.Errorf(msg)
} else {
return err2
}
}
}
}
// Directory: create new directory, also type change to directory
if file.Mode.IsDir() && (change.Created || changeType) {
debugf("creating directory")
err := unix.Mkdirat(parentFd, baseName, 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 and fchmodat is
// incomplete so open the directory.
debugf("chmodding %s", file.Mode)
dh, err := OpenAtNoFollow(parentFd, baseName)
if err != nil {
return err
}
defer dh.Close()
err = dh.Chmod(file.Mode)
if err != nil {
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 {
return err
}
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
// Retain setgid/sticky so that the behavior does not
// change when creating and removing files.
mode |= change.Old.Mode & fs.ModeSetgid
mode |= change.Old.Mode & fs.ModeSticky
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 := slashpath.Dir(file.Path) // only used in debug messages
// Create hidden file which should be ignored by most other tools and
// thus not affect anything during creation
tmpBase := "." + baseName
switch file.Mode.Type() {
case 0: // regular file
debugf("creating temporary file %q",
slashpath.Join(dir, tmpBase+"*"))
x, err := WriteTempAt(parentFd, tmpBase, file.Data,
file.Uid, file.Gid, file.Mode)
if err != nil {
return err
}
tmpBase = x
case fs.ModeSymlink:
i := 0
retry:
x := tmpBase + strconv.Itoa(rand.Int())
debugf("creating temporary symlink %q",
slashpath.Join(dir, x))
err := unix.Symlinkat(string(file.Data), parentFd, x)
if err != nil {
if os.IsExist(err) && i < 10000 {
i++
goto retry
}
return err
}
tmpBase = x
err = unix.Fchownat(parentFd, tmpBase, file.Uid, file.Gid,
unix.AT_SYMLINK_NOFOLLOW)
if err != nil {
unix.Unlinkat(parentFd, tmpBase, 0 /* flags */) //nolint:errcheck
return err
}
// Permissions are irrelevant for symlinks (on most systems)
default:
panic(fmt.Sprintf("invalid file type %s", file.Mode))
}
debugf("renaming %q", slashpath.Join(dir, tmpBase))
err = unix.Renameat(parentFd, tmpBase, parentFd, baseName)
if err != nil {
unix.Unlinkat(parentFd, tmpBase, 0 /* flags */) //nolint:errcheck
return err
}
// To guarantee durability fsync 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."
err = unix.Fsync(parentFd)
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
}
// OpenParentDirectoryNoSymlinks opens the dirname of path without following
// any symlinks and returns a file descriptor to it and the basename of path.
// To prevent symlink attacks in earlier path components when these are
// writable by other users it starts at the root (or current directory for
// relative paths) and uses openat (with O_NOFOLLOW) for each path component.
// If a symlink is encountered it returns an error. However, it's impossible
// to guarantee that the returned descriptor refers to the same location as
// given in path because users with write access can rename path components.
// But this is not required to prevent the mentioned attacks.
func OpenParentDirectoryNoSymlinks(path string) (int, string, error) {
// Slash separated paths are used for the configuration
parts := strings.Split(path, "/")
var dir string
if path == "/" {
// Root: use root itself as base name because root is the
// parent of itself
dir = "/"
parts = []string{"/"}
} else if parts[0] == "" {
// Absolute path
dir = "/"
parts = parts[1:]
} else if path == "." {
// Current directory: open parent directory and use current
// directory name as base name
wd, err := os.Getwd()
if err != nil {
return -1, "", fmt.Errorf(
"failed to get working directory: %w", err)
}
dir = ".."
parts = []string{filepath.Base(wd)}
} else {
// Relative path: start at the current directory
dir = "."
if parts[0] == "." {
parts = parts[1:]
}
}
dirFd, err := unix.Openat(unix.AT_FDCWD, dir,
openReadonlyFlags, 0 /* mode */)
if err != nil {
return -1, "", err
}
// Walk path one directory at a time to ensure there are no symlinks
// in the path. This prevents users with write access to change the
// path to point to arbitrary locations. O_NOFOLLOW when opening the
// path is not enough as only the last path component is checked.
for i, name := range parts[:len(parts)-1] {
fd, err := unix.Openat(dirFd, name, openReadonlyFlags, 0)
if err != nil {
unix.Close(dirFd)
if err == unix.ELOOP || err == unix.EMLINK {
x := filepath.Join(append([]string{dir},
parts[:i+1]...)...)
return -1, "", fmt.Errorf(
"symlink not permitted in path: %q",
x)
}
return -1, "", err
}
unix.Close(dirFd)
dirFd = fd
}
return dirFd, parts[len(parts)-1], 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("\n",
len(oldData)))
}
if newBin {
newData = []byte(fmt.Sprintf("\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 OpenFileNoSymlinks(path string) (*os.File, error) {
parentFd, baseName, err := OpenParentDirectoryNoSymlinks(path)
if err != nil {
return nil, err
}
defer unix.Close(parentFd)
return OpenAtNoFollow(parentFd, baseName)
}
func OpenAtNoFollow(dirFd int, base string) (*os.File, error) {
fd, err := unix.Openat(dirFd, base, openReadonlyFlags, 0 /* mode */)
if err != nil {
return nil, err
}
return os.NewFile(uintptr(fd), ""), nil
}
func WriteTempAt(dirFd int, base string, data []byte, uid, gid int,
mode fs.FileMode) (string, error) {
fh, tmpBase, err := createTempAt(dirFd, base)
if err != nil {
return "", err
}
_, err = fh.Write(data)
if err != nil {
fh.Close()
unix.Unlinkat(dirFd, tmpBase, 0 /* flags */) //nolint:errcheck
return "", err
}
// createTempAt() creates the file with 0600
err = fh.Chown(uid, gid)
if err != nil {
fh.Close()
unix.Unlinkat(dirFd, tmpBase, 0) //nolint:errcheck
return "", err
}
err = fh.Chmod(mode)
if err != nil {
fh.Close()
unix.Unlinkat(dirFd, tmpBase, 0) //nolint:errcheck
return "", err
}
err = fh.Sync()
if err != nil {
fh.Close()
unix.Unlinkat(dirFd, tmpBase, 0) //nolint:errcheck
return "", err
}
err = fh.Close()
if err != nil {
unix.Unlinkat(dirFd, tmpBase, 0) //nolint:errcheck
return "", err
}
return tmpBase, nil
}
// createTempAt works similar to os.CreateTemp but uses unix.Openat to create
// the temporary file.
func createTempAt(dirFd int, base string) (*os.File, string, error) {
var tmpBase string
i := 0
retry:
tmpBase = base + strconv.Itoa(rand.Int())
fd, err := unix.Openat(dirFd, tmpBase,
unix.O_RDWR|unix.O_CREAT|unix.O_EXCL, 0600)
if err != nil {
if os.IsExist(err) && i < 10000 {
i++
goto retry
}
return nil, "", err
}
return os.NewFile(uintptr(fd), ""), tmpBase, nil
}