]> ruderich.org/simon Gitweb - safcm/safcm.git/blob - cmd/safcm-remote/sync/files.go
safcm: add experimental support to sync from Windows hosts
[safcm/safcm.git] / cmd / safcm-remote / sync / files.go
1 // MsgSyncReq: copy files to the remote host
2
3 // Copyright (C) 2021  Simon Ruderich
4 //
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.
9 //
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.
14 //
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/>.
17
18 // +build !windows
19
20 package sync
21
22 import (
23         "bytes"
24         "fmt"
25         "io"
26         "io/fs"
27         "math/rand"
28         "net/http"
29         "os"
30         "os/user"
31         "path/filepath"
32         "sort"
33         "strconv"
34         "strings"
35         "syscall"
36         "time"
37
38         "github.com/ianbruene/go-difflib/difflib"
39
40         "ruderich.org/simon/safcm"
41 )
42
43 func (s *Sync) syncFiles() error {
44         // To create random file names for symlinks
45         rand.Seed(time.Now().UnixNano())
46
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)
52         }
53         sort.Slice(files, func(i, j int) bool {
54                 return files[i].Path < files[j].Path
55         })
56
57         for _, x := range files {
58                 var changed bool
59                 err := s.syncFile(x, &changed)
60                 if err != nil {
61                         return fmt.Errorf("%q: %v", x.Path, err)
62                 }
63                 if changed {
64                         s.queueTriggers(x)
65                 }
66         }
67
68         return nil
69 }
70
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.
82         //
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).
87
88         err := s.fileResolveIds(file)
89         if err != nil {
90                 return err
91         }
92
93         change := safcm.FileChange{
94                 Path: file.Path,
95                 New: safcm.FileChangeInfo{
96                         Mode:  file.Mode,
97                         User:  file.User,
98                         Uid:   file.Uid,
99                         Group: file.Group,
100                         Gid:   file.Gid,
101                 },
102         }
103
104         debugf := func(format string, a ...interface{}) {
105                 s.log.Debugf("files: %q (%s): %s",
106                         file.Path, file.OrigGroup, fmt.Sprintf(format, a...))
107         }
108         verbosef := func(format string, a ...interface{}) {
109                 s.log.Verbosef("files: %q (%s): %s",
110                         file.Path, file.OrigGroup, fmt.Sprintf(format, a...))
111         }
112
113         var oldStat fs.FileInfo
114 reopen:
115         oldFh, err := OpenFileNoFollow(file.Path)
116         if err != nil {
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
121                         // component.
122                         x, err := os.Lstat(file.Path)
123                         if err != nil {
124                                 return err
125                         }
126                         if x.Mode().Type() != fs.ModeSymlink {
127                                 debugf("type changed from symlink to %s, retry",
128                                         x.Mode().Type())
129                                 goto reopen
130                         }
131                         // ELOOP from symbolic link, this is fine
132                         oldStat = x
133                 } else if os.IsNotExist(err) {
134                         change.Created = true
135                         debugf("will create")
136                 } else {
137                         return err
138                 }
139         } else {
140                 defer oldFh.Close()
141
142                 x, err := oldFh.Stat()
143                 if err != nil {
144                         return err
145                 }
146                 oldStat = x
147         }
148
149         var oldData []byte
150         var changeType, changePerm, changeUserOrGroup, changeData bool
151         if !change.Created {
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).
159                         //
160                         // TODO: Add proper support for symlinks on BSD
161                         change.Old.Mode |= 0777
162                 }
163                 if change.Old.Mode != file.Mode {
164                         if change.Old.Mode.Type() != file.Mode.Type() {
165                                 changeType = true
166                                 debugf("type differs %s -> %s",
167                                         change.Old.Mode.Type(),
168                                         file.Mode.Type())
169                         } else {
170                                 // Be careful with .Perm() which does not
171                                 // contain the setuid/setgid/sticky bits!
172                                 changePerm = true
173                                 debugf("permission differs %s -> %s",
174                                         change.Old.Mode, file.Mode)
175                         }
176                 }
177
178                 // Compare user/group
179                 x, ok := oldStat.Sys().(*syscall.Stat_t)
180                 if !ok {
181                         return fmt.Errorf("unsupported Stat().Sys()")
182                 }
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,
189                                 file.Uid, file.Gid)
190                 }
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
194                 // be reported.
195                 if err == nil {
196                         change.Old.User = u
197                         change.Old.Group = g
198                 }
199
200                 // Compare file content (if possible)
201                 switch change.Old.Mode.Type() {
202                 case 0: // regular file
203                         x, err := io.ReadAll(oldFh)
204                         if err != nil {
205                                 return fmt.Errorf("reading old content: %v",
206                                         err)
207                         }
208                         oldData = x
209                 case fs.ModeSymlink:
210                         x, err := os.Readlink(file.Path)
211                         if err != nil {
212                                 return fmt.Errorf("reading old content: %v",
213                                         err)
214                         }
215                         oldData = []byte(x)
216                 }
217                 if !changeType && file.Mode.Type() != fs.ModeDir {
218                         if !bytes.Equal(oldData, file.Data) {
219                                 changeData = true
220                                 debugf("content differs")
221                         }
222                 }
223         }
224         oldStat = nil // prevent accidental use
225
226         // No changes
227         if !change.Created && !changeType &&
228                 !changePerm && !changeUserOrGroup &&
229                 !changeData {
230                 debugf("unchanged")
231                 return nil
232         }
233         *changed = true
234
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)
243                 if err != nil {
244                         return err
245                 }
246         }
247
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)
251
252         if change.Created {
253                 verbosef("creating")
254         } else {
255                 verbosef("updating")
256         }
257
258         if s.req.DryRun {
259                 debugf("dry-run, skipping changes")
260                 return nil
261         }
262
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)
267                 if err != nil {
268                         return err
269                 }
270         }
271
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)
276                 if err != nil {
277                         return err
278                 }
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
282                 // directory.
283                 debugf("chmodding %s", file.Mode)
284                 dh, err := OpenFileNoFollow(file.Path)
285                 if err != nil {
286                         return err
287                 }
288                 err = dh.Chmod(file.Mode)
289                 if err != nil {
290                         dh.Close()
291                         return err
292                 }
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)
297                 if err != nil {
298                         dh.Close()
299                         return err
300                 }
301                 dh.Close()
302                 return nil
303         }
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)
320                         if err != nil {
321                                 return err
322                         }
323                 }
324                 if changeUserOrGroup {
325                         debugf("chowning %d/%d", file.Uid, file.Gid)
326                         err := oldFh.Chown(file.Uid, file.Gid)
327                         if err != nil {
328                                 return err
329                         }
330                 }
331                 if changePerm {
332                         debugf("chmodding %s", file.Mode)
333                         err := oldFh.Chmod(file.Mode)
334                         if err != nil {
335                                 return err
336                         }
337                 }
338                 return nil
339         }
340
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)
345
346         var tmpPath string
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)
353                 if err != nil {
354                         return err
355                 }
356
357         case fs.ModeSymlink:
358                 i := 0
359         retry:
360                 // Similar to os.CreateTemp() but for symlinks which we cannot
361                 // open as file
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)
366                 if err != nil {
367                         if os.IsExist(err) && i < 10000 {
368                                 i++
369                                 goto retry
370                         }
371                         return err
372                 }
373                 err = os.Lchown(tmpPath, file.Uid, file.Gid)
374                 if err != nil {
375                         os.Remove(tmpPath)
376                         return err
377                 }
378                 // Permissions are irrelevant for symlinks (on most systems)
379
380         default:
381                 panic(fmt.Sprintf("invalid file type %s", file.Mode))
382         }
383
384         debugf("renaming %q", tmpPath)
385         err = os.Rename(tmpPath, file.Path)
386         if err != nil {
387                 os.Remove(tmpPath)
388                 return err
389         }
390         err = SyncPath(dir)
391         if err != nil {
392                 return err
393         }
394
395         return nil
396 }
397
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)",
401                         file.User, file.Uid)
402         }
403         if file.Group != "" && file.Gid != -1 {
404                 return fmt.Errorf("cannot set both Group (%q) and Gid (%d)",
405                         file.Group, file.Gid)
406         }
407
408         if file.User == "" && file.Uid == -1 {
409                 file.User = s.defaultUser
410         }
411         if file.User != "" {
412                 x, err := user.Lookup(file.User)
413                 if err != nil {
414                         return err
415                 }
416                 id, err := strconv.Atoi(x.Uid)
417                 if err != nil {
418                         return err
419                 }
420                 file.Uid = id
421         }
422
423         if file.Group == "" && file.Gid == -1 {
424                 file.Group = s.defaultGroup
425         }
426         if file.Group != "" {
427                 x, err := user.LookupGroup(file.Group)
428                 if err != nil {
429                         return err
430                 }
431                 id, err := strconv.Atoi(x.Gid)
432                 if err != nil {
433                         return err
434                 }
435                 file.Gid = id
436         }
437
438         return nil
439 }
440
441 func resolveIdsToNames(uid, gid int) (string, string, error) {
442         u, err := user.LookupId(strconv.Itoa(uid))
443         if err != nil {
444                 return "", "", err
445         }
446         g, err := user.LookupGroupId(strconv.Itoa(gid))
447         if err != nil {
448                 return "", "", err
449         }
450         return u.Username, g.Name, nil
451 }
452
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
459         }
460         if oldBin {
461                 oldData = []byte(fmt.Sprintf("<binary content, %d bytes>\n",
462                         len(oldData)))
463         }
464         if newBin {
465                 newData = []byte(fmt.Sprintf("<binary content, %d bytes>\n",
466                         len(newData)))
467         }
468
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)),
475                 Context: 3,
476         })
477         if err != nil {
478                 return "", err
479         }
480         return result, nil
481 }
482
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)
488 }
489
490 func WriteTemp(dir, base string, data []byte, uid, gid int, mode fs.FileMode) (
491         string, error) {
492
493         fh, err := os.CreateTemp(dir, base)
494         if err != nil {
495                 return "", err
496         }
497         tmpPath := fh.Name()
498
499         _, err = fh.Write(data)
500         if err != nil {
501                 fh.Close()
502                 os.Remove(tmpPath)
503                 return "", err
504         }
505         // CreateTemp() creates the file with 0600
506         err = fh.Chown(uid, gid)
507         if err != nil {
508                 fh.Close()
509                 os.Remove(tmpPath)
510                 return "", err
511         }
512         err = fh.Chmod(mode)
513         if err != nil {
514                 fh.Close()
515                 os.Remove(tmpPath)
516                 return "", err
517         }
518         err = fh.Sync()
519         if err != nil {
520                 fh.Close()
521                 os.Remove(tmpPath)
522                 return "", err
523         }
524         err = fh.Close()
525         if err != nil {
526                 fh.Close()
527                 os.Remove(tmpPath)
528                 return "", err
529         }
530
531         return tmpPath, nil
532 }
533
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
536 // files therein.
537 //
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)
544         if err != nil {
545                 return err
546         }
547         err = x.Sync()
548         closeErr := x.Close()
549         if err != nil {
550                 return err
551         }
552         return closeErr
553 }