1 // MsgSyncReq: copy files to the remote host
3 // Copyright (C) 2021 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/>.
38 "github.com/ianbruene/go-difflib/difflib"
40 "ruderich.org/simon/safcm"
43 func (s *Sync) syncFiles() error {
44 // To create random file names for symlinks
45 rand.Seed(time.Now().UnixNano())
47 // Sort for deterministic order and so parent directories are present
48 // when files in them are created
49 var files []*safcm.File
50 for _, x := range s.req.Files {
51 files = append(files, x)
53 sort.Slice(files, func(i, j int) bool {
54 return files[i].Path < files[j].Path
57 for _, x := range files {
59 err := s.syncFile(x, &changed)
61 return fmt.Errorf("%q: %v", x.Path, err)
71 func (s *Sync) syncFile(file *safcm.File, changed *bool) error {
72 // The general strategy is "update by rename": If any property of a
73 // file changes it will be written to a temporary file and then
74 // renamed "over" the original file. This is simple and prevents race
75 // conditions where the file is partially readable while changes to
76 // permissions or owner/group are applied. However, this strategy does
77 // not work for directories which must be removed first (was
78 // directory), must remove the existing file (will be directory) or
79 // must be directly modified (changed permissions or owner). In the
80 // first two cases the old path is removed. In the last the directory
81 // is modified (carefully) in place.
83 // The implementation is careful not to follow any symlinks to prevent
84 // possible race conditions which can be exploited and are especially
85 // dangerous when running with elevated privileges (which will most
86 // likely be the case).
88 err := s.fileResolveIds(file)
93 change := safcm.FileChange{
95 New: safcm.FileChangeInfo{
104 debugf := func(format string, a ...interface{}) {
105 s.log.Debugf("files: %q (%s): %s",
106 file.Path, file.OrigGroup, fmt.Sprintf(format, a...))
108 verbosef := func(format string, a ...interface{}) {
109 s.log.Verbosef("files: %q (%s): %s",
110 file.Path, file.OrigGroup, fmt.Sprintf(format, a...))
113 var oldStat fs.FileInfo
115 oldFh, err := OpenFileNoFollow(file.Path)
117 err := err.(*fs.PathError)
118 if err.Err == syscall.ELOOP || err.Err == syscall.EMLINK {
119 // Check if ELOOP was caused not by O_NOFOLLOW but by
120 // too many nested symlinks before the final path
122 x, err := os.Lstat(file.Path)
126 if x.Mode().Type() != fs.ModeSymlink {
127 debugf("type changed from symlink to %s, retry",
131 // ELOOP from symbolic link, this is fine
133 } else if os.IsNotExist(err) {
134 change.Created = true
135 debugf("will create")
142 x, err := oldFh.Stat()
150 var changeType, changePerm, changeUserOrGroup, changeData bool
152 // Compare permissions
153 change.Old.Mode = oldStat.Mode()
154 if change.Old.Mode.Type() == fs.ModeSymlink {
155 // Some BSD systems permit changing permissions of
156 // symlinks but ignore them on traversal. To keep it
157 // simple we don't support that and always use 0777
158 // for symlink permissions (the value on GNU/Linux).
160 // TODO: Add proper support for symlinks on BSD
161 change.Old.Mode |= 0777
163 if change.Old.Mode != file.Mode {
164 if change.Old.Mode.Type() != file.Mode.Type() {
166 debugf("type differs %s -> %s",
167 change.Old.Mode.Type(),
170 // Be careful with .Perm() which does not
171 // contain the setuid/setgid/sticky bits!
173 debugf("permission differs %s -> %s",
174 change.Old.Mode, file.Mode)
178 // Compare user/group
179 x, ok := oldStat.Sys().(*syscall.Stat_t)
181 return fmt.Errorf("unsupported Stat().Sys()")
183 change.Old.Uid = int(x.Uid)
184 change.Old.Gid = int(x.Gid)
185 if change.Old.Uid != file.Uid || change.Old.Gid != file.Gid {
186 changeUserOrGroup = true
187 debugf("uid/gid differs %d/%d -> %d/%d",
188 change.Old.Uid, change.Old.Gid,
191 u, g, err := resolveIdsToNames(change.Old.Uid, change.Old.Gid)
192 // Errors are not relevant as this is only used to report the
193 // change. If the user/group no longer exits only the ids will
200 // Compare file content (if possible)
201 switch change.Old.Mode.Type() {
202 case 0: // regular file
203 x, err := io.ReadAll(oldFh)
205 return fmt.Errorf("reading old content: %v",
210 x, err := os.Readlink(file.Path)
212 return fmt.Errorf("reading old content: %v",
217 if !changeType && file.Mode.Type() != fs.ModeDir {
218 if !bytes.Equal(oldData, file.Data) {
220 debugf("content differs")
224 oldStat = nil // prevent accidental use
227 if !change.Created && !changeType &&
228 !changePerm && !changeUserOrGroup &&
235 // Don't show a diff with the full content for newly created files or
236 // on type changes. This is just noise for the user as the new file
237 // content is obvious. But we always want to see a diff when files are
238 // replaced because this destroys data.
239 if !change.Created &&
240 (change.Old.Mode.Type() == 0 ||
241 change.Old.Mode.Type() == fs.ModeSymlink) {
242 change.DataDiff, err = diffData(oldData, file.Data)
248 // Add change here so it is stored even when applying it fails. This
249 // way the user knows exactly what was attempted.
250 s.resp.FileChanges = append(s.resp.FileChanges, change)
259 debugf("dry-run, skipping changes")
263 // We cannot rename over directories and vice versa
264 if changeType && (change.Old.Mode.IsDir() || file.Mode.IsDir()) {
265 debugf("removing (due to type change)")
266 err := os.RemoveAll(file.Path)
272 // Directory: create new directory, also type change to directory
273 if file.Mode.IsDir() && (change.Created || changeType) {
274 debugf("creating directory")
275 err := os.Mkdir(file.Path, 0700)
279 // We must be careful not to chmod arbitrary files. If the
280 // target directory is writable then it might have changed to
281 // a symlink at this point. There's no lchmod so open the
283 debugf("chmodding %s", file.Mode)
284 dh, err := OpenFileNoFollow(file.Path)
288 err = dh.Chmod(file.Mode)
293 // Less restrictive access is not relevant here because there
294 // are no files present yet.
295 debugf("chowning %d/%d", file.Uid, file.Gid)
296 err = dh.Chown(file.Uid, file.Gid)
304 // Directory: changed permission or user/group
305 if file.Mode.IsDir() {
306 // We don't know if the new permission or if the new
307 // user/group is more restrictive (e.g. root:root 0750 ->
308 // user:group 0700; applying group first gives group
309 // unexpected access). To prevent a short window where the
310 // access might be too lax we temporarily deny all access.
311 if changePerm && changeUserOrGroup {
312 // Only drop group and other permission because user
313 // has access anyway (either before or after the
314 // change). This also prevents temporary errors during
315 // the error when the user tries to access this
316 // directory (access for the group will fail though).
317 mode := change.Old.Mode & fs.ModePerm & 0700
318 debugf("chmodding %#o (temporary)", mode)
319 err := oldFh.Chmod(mode)
324 if changeUserOrGroup {
325 debugf("chowning %d/%d", file.Uid, file.Gid)
326 err := oldFh.Chown(file.Uid, file.Gid)
332 debugf("chmodding %s", file.Mode)
333 err := oldFh.Chmod(file.Mode)
341 dir := filepath.Dir(file.Path)
342 // Create hidden file which should be ignored by most other tools and
343 // thus not affect anything during creation
344 base := "." + filepath.Base(file.Path)
347 switch file.Mode.Type() {
348 case 0: // regular file
349 debugf("creating temporary file %q",
350 filepath.Join(dir, base+"*"))
351 tmpPath, err = WriteTemp(dir, base, file.Data,
352 file.Uid, file.Gid, file.Mode)
360 // Similar to os.CreateTemp() but for symlinks which we cannot
362 tmpPath = filepath.Join(dir,
363 base+strconv.Itoa(rand.Int()))
364 debugf("creating temporary symlink %q", tmpPath)
365 err := os.Symlink(string(file.Data), tmpPath)
367 if os.IsExist(err) && i < 10000 {
373 err = os.Lchown(tmpPath, file.Uid, file.Gid)
378 // Permissions are irrelevant for symlinks (on most systems)
381 panic(fmt.Sprintf("invalid file type %s", file.Mode))
384 debugf("renaming %q", tmpPath)
385 err = os.Rename(tmpPath, file.Path)
398 func (s *Sync) fileResolveIds(file *safcm.File) error {
399 if file.User != "" && file.Uid != -1 {
400 return fmt.Errorf("cannot set both User (%q) and Uid (%d)",
403 if file.Group != "" && file.Gid != -1 {
404 return fmt.Errorf("cannot set both Group (%q) and Gid (%d)",
405 file.Group, file.Gid)
408 if file.User == "" && file.Uid == -1 {
409 file.User = s.defaultUser
412 x, err := user.Lookup(file.User)
416 id, err := strconv.Atoi(x.Uid)
423 if file.Group == "" && file.Gid == -1 {
424 file.Group = s.defaultGroup
426 if file.Group != "" {
427 x, err := user.LookupGroup(file.Group)
431 id, err := strconv.Atoi(x.Gid)
441 func resolveIdsToNames(uid, gid int) (string, string, error) {
442 u, err := user.LookupId(strconv.Itoa(uid))
446 g, err := user.LookupGroupId(strconv.Itoa(gid))
450 return u.Username, g.Name, nil
453 func diffData(oldData []byte, newData []byte) (string, error) {
454 oldBin := !strings.HasPrefix(http.DetectContentType(oldData), "text/")
455 newBin := !strings.HasPrefix(http.DetectContentType(newData), "text/")
456 if oldBin && newBin {
457 return fmt.Sprintf("Binary files differ (%d -> %d bytes), "+
458 "cannot show diff", len(oldData), len(newData)), nil
461 oldData = []byte(fmt.Sprintf("<binary content, %d bytes>\n",
465 newData = []byte(fmt.Sprintf("<binary content, %d bytes>\n",
469 // TODO: difflib shows empty context lines at the end of the file
470 // which should not be there
471 // TODO: difflib has issues with missing newlines in either side
472 result, err := difflib.GetUnifiedDiffString(difflib.LineDiffParams{
473 A: difflib.SplitLines(string(oldData)),
474 B: difflib.SplitLines(string(newData)),
483 func OpenFileNoFollow(path string) (*os.File, error) {
484 return os.OpenFile(path,
485 // O_NOFOLLOW prevents symlink attacks
486 // O_NONBLOCK is necessary to prevent blocking on FIFOs
487 os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
490 func WriteTemp(dir, base string, data []byte, uid, gid int, mode fs.FileMode) (
493 fh, err := os.CreateTemp(dir, base)
499 _, err = fh.Write(data)
505 // CreateTemp() creates the file with 0600
506 err = fh.Chown(uid, gid)
534 // SyncPath syncs path, which should be a directory. To guarantee durability
535 // it must be called on a parent directory after adding, renaming or removing
538 // Calling sync on the files itself is not enough according to POSIX; man 2
539 // fsync: "Calling fsync() does not necessarily ensure that the entry in the
540 // directory containing the file has also reached disk. For that an explicit
541 // fsync() on a file descriptor for the directory is also needed."
542 func SyncPath(path string) error {
543 x, err := os.Open(path)
548 closeErr := x.Close()