X-Git-Url: https://ruderich.org/simon/gitweb/?a=blobdiff_plain;f=cmd%2Fsafcm-remote%2Fsync%2Ffiles.go;fp=cmd%2Fsafcm-remote%2Fsync%2Ffiles.go;h=0000000000000000000000000000000000000000;hb=9269fa3c94e700afc0be823f58ea473a2db8f3dc;hp=e0a2221009deda9824aa38419fec6ffc1decc0f0;hpb=fd97e8019e2ab166d9475ed59782c86247d8430b;p=safcm%2Fsafcm.git diff --git a/cmd/safcm-remote/sync/files.go b/cmd/safcm-remote/sync/files.go deleted file mode 100644 index e0a2221..0000000 --- a/cmd/safcm-remote/sync/files.go +++ /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 . - -// +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("\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 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 -}