1 // MsgSyncReq: copy files to the remote host
3 // Copyright (C) 2021-2024 Simon Ruderich
5 // This program is free software: you can redistribute it and/or modify
6 // it under the terms of the GNU General Public License as published by
7 // the Free Software Foundation, either version 3 of the License, or
8 // (at your option) any later version.
10 // This program is distributed in the hope that it will be useful,
11 // but WITHOUT ANY WARRANTY; without even the implied warranty of
12 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 // GNU General Public License for more details.
15 // You should have received a copy of the GNU General Public License
16 // along with this program. If not, see <http://www.gnu.org/licenses/>.
39 "github.com/ianbruene/go-difflib/difflib"
40 "golang.org/x/sys/unix"
42 "ruderich.org/simon/safcm"
45 // NOTE: Don't use plain os.Open, os.OpenFile, os.Remove or any other function
46 // which uses absolute paths. These are vulnerable to symlink attacks. Always
47 // use *at syscalls. See below for details.
49 // openReadonlyFlags are flags for open* syscalls to safely read a file or
52 // O_NOFOLLOW prevents symlink attacks in the last path component
53 // O_NONBLOCK is necessary to prevent blocking on FIFOs
54 const openReadonlyFlags = unix.O_RDONLY | unix.O_NOFOLLOW | unix.O_NONBLOCK
56 func (s *Sync) syncFiles() error {
57 // To create random file names for symlinks
58 rand.Seed(time.Now().UnixNano())
60 // Sort for deterministic order and so parent directories are present
61 // when files in them are created
62 var files []*safcm.File
63 for _, x := range s.req.Files {
64 files = append(files, x)
66 sort.Slice(files, func(i, j int) bool {
67 return files[i].Path < files[j].Path
70 for _, x := range files {
72 err := s.syncFile(x, &changed)
74 return fmt.Errorf("%q: %v", x.Path, err)
84 func (s *Sync) syncFile(file *safcm.File, changed *bool) error {
85 // The general strategy is "update by rename": If any property of a
86 // file changes the new version will be written to a temporary file
87 // and then renamed "over" the original file. This is simple and
88 // prevents race conditions where the file is partially readable while
89 // changes to permissions or owner/group are applied. However, this
90 // strategy does not work for directories which must be removed first
91 // (was directory), must remove the existing file (will be directory)
92 // or must be directly modified (changed permissions or owner). In the
93 // first two cases the old path is removed. In the last the directory
94 // is modified (carefully) in place.
96 // The implementation is careful not to follow any symlinks to prevent
97 // possible race conditions which can be exploited and are especially
98 // dangerous when running with elevated privileges (which will most
99 // likely be the case). This includes not using absolute paths in
100 // syscalls to prevent symlink attacks when a directory is writable by
101 // other users (e.g. when syncing a file to /home/user/dir/file the
102 // user could create dir as symlink to another directory and file
103 // would be written there). To prevent this *at syscalls are used and
104 // all symlinks in the path are rejected. This still permits the user
105 // to move dir during the sync but only to places which are writable
106 // by the user which cannot be prevented.
108 err := s.fileResolveIds(file)
113 change := safcm.FileChange{
115 New: safcm.FileChangeInfo{
124 debugf := func(format string, a ...interface{}) {
125 s.log.Debugf("files: %q (%s): %s",
126 file.Path, file.OrigGroup, fmt.Sprintf(format, a...))
128 verbosef := func(format string, a ...interface{}) {
129 s.log.Verbosef("files: %q (%s): %s",
130 file.Path, file.OrigGroup, fmt.Sprintf(format, a...))
133 parentFd, baseName, err := OpenParentDirectoryNoSymlinks(file.Path)
135 if os.IsNotExist(err) && s.req.DryRun {
136 change.Created = true
137 debugf("will create (parent missing)")
139 debugf("dry-run, skipping changes")
140 s.resp.FileChanges = append(s.resp.FileChanges, change)
145 defer unix.Close(parentFd)
147 var oldStat unix.Stat_t
149 oldFh, err := OpenAtNoFollow(parentFd, baseName)
151 if err == unix.ELOOP || err == unix.EMLINK {
152 // ELOOP from symbolic link, this is fine
153 err := unix.Fstatat(parentFd, baseName, &oldStat,
154 unix.AT_SYMLINK_NOFOLLOW)
158 if oldStat.Mode&unix.S_IFMT != unix.S_IFLNK {
159 debugf("type changed from symlink, retrying")
162 } else if os.IsNotExist(err) {
163 change.Created = true
164 debugf("will create")
171 err := unix.Fstat(int(oldFh.Fd()), &oldStat)
178 var changeType, changePerm, changeUserOrGroup, changeData bool
180 // Manually convert to FileMode; from src/os/stat_linux.go in
181 // Go's sources (stat_*.go for other UNIX systems are
182 // identical, except for stat_darwin.go which has an extra
184 mode := fs.FileMode(oldStat.Mode & 0777)
185 switch oldStat.Mode & unix.S_IFMT {
187 mode |= fs.ModeDevice
189 mode |= fs.ModeDevice | fs.ModeCharDevice
193 mode |= fs.ModeNamedPipe
195 mode |= fs.ModeSymlink
199 mode |= fs.ModeSocket
200 // Guard against unknown file types (e.g. on darwin); not in
203 return fmt.Errorf("unexpected file mode %v",
204 oldStat.Mode&unix.S_IFMT)
206 if oldStat.Mode&unix.S_ISGID != 0 {
207 mode |= fs.ModeSetgid
209 if oldStat.Mode&unix.S_ISUID != 0 {
210 mode |= fs.ModeSetuid
212 if oldStat.Mode&unix.S_ISVTX != 0 {
213 mode |= fs.ModeSticky
216 // Compare permissions
217 change.Old.Mode = mode
218 if change.Old.Mode.Type() == fs.ModeSymlink {
219 // Some BSD systems permit changing permissions of
220 // symlinks but ignore them on traversal. To keep it
221 // simple we don't support that and always use 0777
222 // for symlink permissions (the value on GNU/Linux)
223 // when comparing. The actual permissions on the file
224 // system might be different on BSD systems.
226 // TODO: Add proper support for symlinks on BSD
227 change.Old.Mode |= 0777
229 if change.Old.Mode != file.Mode {
230 if change.Old.Mode.Type() != file.Mode.Type() {
232 debugf("type differs %s -> %s",
233 change.Old.Mode.Type(),
236 // Be careful with .Perm() which does not
237 // contain the setuid/setgid/sticky bits!
239 debugf("permission differs %s -> %s",
240 change.Old.Mode, file.Mode)
244 // Compare user/group
245 change.Old.Uid = int(oldStat.Uid)
246 change.Old.Gid = int(oldStat.Gid)
247 if change.Old.Uid != file.Uid || change.Old.Gid != file.Gid {
248 changeUserOrGroup = true
249 debugf("uid/gid differs %d/%d -> %d/%d",
250 change.Old.Uid, change.Old.Gid,
253 u, g, err := resolveIdsToNames(change.Old.Uid, change.Old.Gid)
254 // Errors are not relevant as this is only used to report the
255 // change. If the user/group no longer exits only the ids will
262 // Compare file content (if possible)
263 switch change.Old.Mode.Type() { //nolint:exhaustive
264 case 0: // regular file
265 x, err := io.ReadAll(oldFh)
267 return fmt.Errorf("reading old content: %v",
272 buf := make([]byte, unix.PathMax)
273 n, err := unix.Readlinkat(parentFd, baseName, buf)
275 return fmt.Errorf("reading old content: %v",
280 if !changeType && file.Mode.Type() != fs.ModeDir {
281 if !bytes.Equal(oldData, file.Data) {
283 debugf("content differs")
289 if !change.Created && !changeType &&
290 !changePerm && !changeUserOrGroup &&
297 // Don't show a diff with the full content for newly created files or
298 // on type changes. This is just noise for the user as the new file
299 // content is obvious. But we always want to see a diff when files are
300 // replaced because this destroys data.
301 if !change.Created &&
302 (change.Old.Mode.Type() == 0 ||
303 change.Old.Mode.Type() == fs.ModeSymlink) {
304 change.DataDiff, err = diffData(oldData, file.Data)
310 // Add change here so it is stored even when applying it fails. This
311 // way the user knows exactly what was attempted.
312 s.resp.FileChanges = append(s.resp.FileChanges, change)
321 debugf("dry-run, skipping changes")
325 // We cannot rename over directories and vice versa
326 if changeType && (change.Old.Mode.IsDir() || file.Mode.IsDir()) {
327 debugf("removing (due to type change)")
328 // In the past os.RemoveAll() was used here. However, this is
329 // difficult to implement manually with *at syscalls. To keep
330 // it simple only permit removing files and empty directories
331 // here. This also has the bonus of preventing data loss when
332 // (accidentally) replacing a directory tree with a file.
333 const msg = "will not replace non-empty directory, " +
334 "please remove manually"
335 err := unix.Unlinkat(parentFd, baseName, 0 /* flags */)
336 if err != nil && !os.IsNotExist(err) {
337 err2 := unix.Unlinkat(parentFd, baseName,
339 if err2 != nil && !os.IsNotExist(err2) {
340 // See src/os/file_unix.go in Go's sources
341 if err2 == unix.ENOTDIR {
343 } else if err2 == unix.ENOTEMPTY {
344 return fmt.Errorf(msg)
352 // Directory: create new directory, also type change to directory
353 if file.Mode.IsDir() && (change.Created || changeType) {
354 debugf("creating directory")
355 err := unix.Mkdirat(parentFd, baseName, 0700)
359 // We must be careful not to chmod arbitrary files. If the
360 // target directory is writable then it might have changed to
361 // a symlink at this point. There's no lchmod and fchmodat is
362 // incomplete so open the directory.
363 debugf("chmodding %s", file.Mode)
364 dh, err := OpenAtNoFollow(parentFd, baseName)
370 err = dh.Chmod(file.Mode)
374 // Less restrictive access is not relevant here because there
375 // are no files present yet.
376 debugf("chowning %d/%d", file.Uid, file.Gid)
377 err = dh.Chown(file.Uid, file.Gid)
383 // Directory: changed permission or user/group
384 if file.Mode.IsDir() {
385 // We don't know if the new permission or if the new
386 // user/group is more restrictive (e.g. root:root 0750 ->
387 // user:group 0700; applying group first gives group
388 // unexpected access). To prevent a short window where the
389 // access might be too lax we temporarily deny all access.
390 if changePerm && changeUserOrGroup {
391 // Only drop group and other permission because user
392 // has access anyway (either before or after the
393 // change). This also prevents temporary errors during
394 // the error when the user tries to access this
395 // directory (access for the group will fail though).
396 mode := change.Old.Mode & fs.ModePerm & 0700
397 // Retain setgid/sticky so that the behavior does not
398 // change when creating and removing files.
399 mode |= change.Old.Mode & fs.ModeSetgid
400 mode |= change.Old.Mode & fs.ModeSticky
401 debugf("chmodding %#o (temporary)", mode)
402 err := oldFh.Chmod(mode)
407 if changeUserOrGroup {
408 debugf("chowning %d/%d", file.Uid, file.Gid)
409 err := oldFh.Chown(file.Uid, file.Gid)
415 debugf("chmodding %s", file.Mode)
416 err := oldFh.Chmod(file.Mode)
424 dir := slashpath.Dir(file.Path) // only used in debug messages
425 // Create hidden file which should be ignored by most other tools and
426 // thus not affect anything during creation
427 tmpBase := "." + baseName
429 switch file.Mode.Type() {
430 case 0: // regular file
431 debugf("creating temporary file %q",
432 slashpath.Join(dir, tmpBase+"*"))
433 x, err := WriteTempAt(parentFd, tmpBase, file.Data,
434 file.Uid, file.Gid, file.Mode)
443 x := tmpBase + strconv.Itoa(rand.Int())
444 debugf("creating temporary symlink %q",
445 slashpath.Join(dir, x))
446 err := unix.Symlinkat(string(file.Data), parentFd, x)
448 if os.IsExist(err) && i < 10000 {
456 err = unix.Fchownat(parentFd, tmpBase, file.Uid, file.Gid,
457 unix.AT_SYMLINK_NOFOLLOW)
459 unix.Unlinkat(parentFd, tmpBase, 0 /* flags */) //nolint:errcheck
462 // Permissions are irrelevant for symlinks (on most systems)
465 panic(fmt.Sprintf("invalid file type %s", file.Mode))
468 debugf("renaming %q", slashpath.Join(dir, tmpBase))
469 err = unix.Renameat(parentFd, tmpBase, parentFd, baseName)
471 unix.Unlinkat(parentFd, tmpBase, 0 /* flags */) //nolint:errcheck
474 // To guarantee durability fsync must be called on a parent directory
475 // after adding, renaming or removing files therein.
477 // Calling sync on the files itself is not enough according to POSIX;
478 // man 2 fsync: "Calling fsync() does not necessarily ensure that the
479 // entry in the directory containing the file has also reached disk.
480 // For that an explicit fsync() on a file descriptor for the directory
482 err = unix.Fsync(parentFd)
490 func (s *Sync) fileResolveIds(file *safcm.File) error {
491 if file.User != "" && file.Uid != -1 {
492 return fmt.Errorf("cannot set both User (%q) and Uid (%d)",
495 if file.Group != "" && file.Gid != -1 {
496 return fmt.Errorf("cannot set both Group (%q) and Gid (%d)",
497 file.Group, file.Gid)
500 if file.User == "" && file.Uid == -1 {
501 file.User = s.defaultUser
504 x, err := user.Lookup(file.User)
508 id, err := strconv.Atoi(x.Uid)
515 if file.Group == "" && file.Gid == -1 {
516 file.Group = s.defaultGroup
518 if file.Group != "" {
519 x, err := user.LookupGroup(file.Group)
523 id, err := strconv.Atoi(x.Gid)
533 // OpenParentDirectoryNoSymlinks opens the dirname of path without following
534 // any symlinks and returns a file descriptor to it and the basename of path.
535 // To prevent symlink attacks in earlier path components when these are
536 // writable by other users it starts at the root (or current directory for
537 // relative paths) and uses openat (with O_NOFOLLOW) for each path component.
538 // If a symlink is encountered it returns an error. However, it's impossible
539 // to guarantee that the returned descriptor refers to the same location as
540 // given in path because users with write access can rename path components.
541 // But this is not required to prevent the mentioned attacks.
542 func OpenParentDirectoryNoSymlinks(path string) (int, string, error) {
543 // Slash separated paths are used for the configuration
544 parts := strings.Split(path, "/")
548 // Root: use root itself as base name because root is the
551 parts = []string{"/"}
552 } else if parts[0] == "" {
556 } else if path == "." {
557 // Current directory: open parent directory and use current
558 // directory name as base name
559 wd, err := os.Getwd()
561 return -1, "", fmt.Errorf(
562 "failed to get working directory: %w", err)
565 parts = []string{filepath.Base(wd)}
567 // Relative path: start at the current directory
574 dirFd, err := unix.Openat(unix.AT_FDCWD, dir,
575 openReadonlyFlags, 0 /* mode */)
579 // Walk path one directory at a time to ensure there are no symlinks
580 // in the path. This prevents users with write access to change the
581 // path to point to arbitrary locations. O_NOFOLLOW when opening the
582 // path is not enough as only the last path component is checked.
583 for i, name := range parts[:len(parts)-1] {
584 fd, err := unix.Openat(dirFd, name, openReadonlyFlags, 0)
587 if err == unix.ELOOP || err == unix.EMLINK {
588 x := filepath.Join(append([]string{dir},
590 return -1, "", fmt.Errorf(
591 "symlink not permitted in path: %q",
600 return dirFd, parts[len(parts)-1], nil
603 func resolveIdsToNames(uid, gid int) (string, string, error) {
604 u, err := user.LookupId(strconv.Itoa(uid))
608 g, err := user.LookupGroupId(strconv.Itoa(gid))
612 return u.Username, g.Name, nil
615 func diffData(oldData []byte, newData []byte) (string, error) {
616 oldBin := !strings.HasPrefix(http.DetectContentType(oldData), "text/")
617 newBin := !strings.HasPrefix(http.DetectContentType(newData), "text/")
618 if oldBin && newBin {
619 return fmt.Sprintf("Binary files differ (%d -> %d bytes), "+
620 "cannot show diff", len(oldData), len(newData)), nil
623 oldData = []byte(fmt.Sprintf("<binary content, %d bytes>\n",
627 newData = []byte(fmt.Sprintf("<binary content, %d bytes>\n",
631 // TODO: difflib shows empty context lines at the end of the file
632 // which should not be there
633 // TODO: difflib has issues with missing newlines in either side
634 result, err := difflib.GetUnifiedDiffString(difflib.LineDiffParams{
635 A: difflib.SplitLines(string(oldData)),
636 B: difflib.SplitLines(string(newData)),
645 func OpenFileNoSymlinks(path string) (*os.File, error) {
646 parentFd, baseName, err := OpenParentDirectoryNoSymlinks(path)
650 defer unix.Close(parentFd)
651 return OpenAtNoFollow(parentFd, baseName)
654 func OpenAtNoFollow(dirFd int, base string) (*os.File, error) {
655 fd, err := unix.Openat(dirFd, base, openReadonlyFlags, 0 /* mode */)
659 return os.NewFile(uintptr(fd), ""), nil
662 func WriteTempAt(dirFd int, base string, data []byte, uid, gid int,
663 mode fs.FileMode) (string, error) {
665 fh, tmpBase, err := createTempAt(dirFd, base)
670 _, err = fh.Write(data)
673 unix.Unlinkat(dirFd, tmpBase, 0 /* flags */) //nolint:errcheck
676 // createTempAt() creates the file with 0600
677 err = fh.Chown(uid, gid)
680 unix.Unlinkat(dirFd, tmpBase, 0) //nolint:errcheck
686 unix.Unlinkat(dirFd, tmpBase, 0) //nolint:errcheck
692 unix.Unlinkat(dirFd, tmpBase, 0) //nolint:errcheck
697 unix.Unlinkat(dirFd, tmpBase, 0) //nolint:errcheck
704 // createTempAt works similar to os.CreateTemp but uses unix.Openat to create
705 // the temporary file.
706 func createTempAt(dirFd int, base string) (*os.File, string, error) {
711 tmpBase = base + strconv.Itoa(rand.Int())
713 fd, err := unix.Openat(dirFd, tmpBase,
714 unix.O_RDWR|unix.O_CREAT|unix.O_EXCL, 0600)
716 if os.IsExist(err) && i < 10000 {
723 return os.NewFile(uintptr(fd), ""), tmpBase, nil