]> ruderich.org/simon Gitweb - safcm/safcm.git/blob - cmd/safcm-remote/sync/files.go
3e88be2b6f2ff78ead5af113846fc6cc302c1f0f
[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 package sync
19
20 import (
21         "bytes"
22         "fmt"
23         "io"
24         "io/fs"
25         "math/rand"
26         "net/http"
27         "os"
28         "os/user"
29         "path/filepath"
30         "sort"
31         "strconv"
32         "strings"
33         "syscall"
34         "time"
35
36         "github.com/ianbruene/go-difflib/difflib"
37
38         "ruderich.org/simon/safcm"
39 )
40
41 func (s *Sync) syncFiles() error {
42         // To create random file names for symlinks
43         rand.Seed(time.Now().UnixNano())
44
45         // Sort for deterministic order and so parent directories are present
46         // when files in them are created
47         var files []*safcm.File
48         for _, x := range s.req.Files {
49                 files = append(files, x)
50         }
51         sort.Slice(files, func(i, j int) bool {
52                 return files[i].Path < files[j].Path
53         })
54
55         for _, x := range files {
56                 var changed bool
57                 err := s.syncFile(x, &changed)
58                 if err != nil {
59                         return fmt.Errorf("%q: %v", x.Path, err)
60                 }
61                 if changed {
62                         s.queueTriggers(x)
63                 }
64         }
65
66         return nil
67 }
68
69 func (s *Sync) syncFile(file *safcm.File, changed *bool) error {
70         // The general strategy is "update by rename": If any property of a
71         // file changes it will be written to a temporary file and then
72         // renamed "over" the original file. This is simple and prevents race
73         // conditions where the file is partially readable while changes to
74         // permissions or owner/group are applied. However, this strategy does
75         // not work for directories which must be removed first (was
76         // directory), must remove the existing file (will be directory) or
77         // must be directly modified (changed permissions or owner). In the
78         // first two cases the old path is removed. In the last the directory
79         // is modified (carefully) in place.
80         //
81         // The implementation is careful not to follow any symlinks to prevent
82         // possible race conditions which can be exploited and are especially
83         // dangerous when running with elevated privileges (which will most
84         // likely be the case).
85
86         err := s.fileResolveIds(file)
87         if err != nil {
88                 return err
89         }
90
91         change := safcm.FileChange{
92                 Path: file.Path,
93                 New: safcm.FileChangeInfo{
94                         Mode:  file.Mode,
95                         User:  file.User,
96                         Uid:   file.Uid,
97                         Group: file.Group,
98                         Gid:   file.Gid,
99                 },
100         }
101
102         debugf := func(format string, a ...interface{}) {
103                 s.log.Debugf("files: %q (%s): %s",
104                         file.Path, file.OrigGroup, fmt.Sprintf(format, a...))
105         }
106         verbosef := func(format string, a ...interface{}) {
107                 s.log.Verbosef("files: %q (%s): %s",
108                         file.Path, file.OrigGroup, fmt.Sprintf(format, a...))
109         }
110
111         var oldStat fs.FileInfo
112 reopen:
113         oldFh, err := OpenFileNoFollow(file.Path)
114         if err != nil {
115                 err := err.(*fs.PathError)
116                 if err.Err == syscall.ELOOP || err.Err == syscall.EMLINK {
117                         // Check if ELOOP was caused not by O_NOFOLLOW but by
118                         // too many nested symlinks before the final path
119                         // component.
120                         x, err := os.Lstat(file.Path)
121                         if err != nil {
122                                 return err
123                         }
124                         if x.Mode().Type() != fs.ModeSymlink {
125                                 debugf("type changed from symlink to %s, retry",
126                                         x.Mode().Type())
127                                 goto reopen
128                         }
129                         // ELOOP from symbolic link, this is fine
130                         oldStat = x
131                 } else if os.IsNotExist(err) {
132                         change.Created = true
133                         debugf("will create")
134                 } else {
135                         return err
136                 }
137         } else {
138                 defer oldFh.Close()
139
140                 x, err := oldFh.Stat()
141                 if err != nil {
142                         return err
143                 }
144                 oldStat = x
145         }
146
147         var oldData []byte
148         var changeType, changePerm, changeUserOrGroup, changeData bool
149         if !change.Created {
150                 // Compare permissions
151                 change.Old.Mode = oldStat.Mode()
152                 if change.Old.Mode.Type() == fs.ModeSymlink {
153                         // Some BSD systems permit changing permissions of
154                         // symlinks but ignore them on traversal. To keep it
155                         // simple we don't support that and always use 0777
156                         // for symlink permissions (the value on GNU/Linux).
157                         //
158                         // TODO: Add proper support for symlinks on BSD
159                         change.Old.Mode |= 0777
160                 }
161                 if change.Old.Mode != file.Mode {
162                         if change.Old.Mode.Type() != file.Mode.Type() {
163                                 changeType = true
164                                 debugf("type differs %s -> %s",
165                                         change.Old.Mode.Type(),
166                                         file.Mode.Type())
167                         } else {
168                                 // Be careful with .Perm() which does not
169                                 // contain the setuid/setgid/sticky bits!
170                                 changePerm = true
171                                 debugf("permission differs %s -> %s",
172                                         change.Old.Mode, file.Mode)
173                         }
174                 }
175
176                 // Compare user/group
177                 x, ok := oldStat.Sys().(*syscall.Stat_t)
178                 if !ok {
179                         return fmt.Errorf("unsupported Stat().Sys()")
180                 }
181                 change.Old.Uid = int(x.Uid)
182                 change.Old.Gid = int(x.Gid)
183                 if change.Old.Uid != file.Uid || change.Old.Gid != file.Gid {
184                         changeUserOrGroup = true
185                         debugf("uid/gid differs %d/%d -> %d/%d",
186                                 change.Old.Uid, change.Old.Gid,
187                                 file.Uid, file.Gid)
188                 }
189                 u, g, err := resolveIdsToNames(change.Old.Uid, change.Old.Gid)
190                 // Errors are not relevant as this is only used to report the
191                 // change. If the user/group no longer exits only the ids will
192                 // be reported.
193                 if err == nil {
194                         change.Old.User = u
195                         change.Old.Group = g
196                 }
197
198                 // Compare file content (if possible)
199                 switch change.Old.Mode.Type() {
200                 case 0: // regular file
201                         x, err := io.ReadAll(oldFh)
202                         if err != nil {
203                                 return fmt.Errorf("reading old content: %v",
204                                         err)
205                         }
206                         oldData = x
207                 case fs.ModeSymlink:
208                         x, err := os.Readlink(file.Path)
209                         if err != nil {
210                                 return fmt.Errorf("reading old content: %v",
211                                         err)
212                         }
213                         oldData = []byte(x)
214                 }
215                 if !changeType && file.Mode.Type() != fs.ModeDir {
216                         if !bytes.Equal(oldData, file.Data) {
217                                 changeData = true
218                                 debugf("content differs")
219                         }
220                 }
221         }
222         oldStat = nil // prevent accidental use
223
224         // No changes
225         if !change.Created && !changeType &&
226                 !changePerm && !changeUserOrGroup &&
227                 !changeData {
228                 debugf("unchanged")
229                 return nil
230         }
231         *changed = true
232
233         // Don't show a diff with the full content for newly created files or
234         // on type changes. This is just noise for the user as the new file
235         // content is obvious. But we always want to see a diff when files are
236         // replaced because this destroys data.
237         if !change.Created &&
238                 (change.Old.Mode.Type() == 0 ||
239                         change.Old.Mode.Type() == fs.ModeSymlink) {
240                 change.DataDiff, err = diffData(oldData, file.Data)
241                 if err != nil {
242                         return err
243                 }
244         }
245
246         // Add change here so it is stored even when applying it fails. This
247         // way the user knows exactly what was attempted.
248         s.resp.FileChanges = append(s.resp.FileChanges, change)
249
250         if change.Created {
251                 verbosef("creating")
252         } else {
253                 verbosef("updating")
254         }
255
256         if s.req.DryRun {
257                 debugf("dry-run, skipping changes")
258                 return nil
259         }
260
261         // We cannot rename over directories and vice versa
262         if changeType && (change.Old.Mode.IsDir() || file.Mode.IsDir()) {
263                 debugf("removing (due to type change)")
264                 err := os.RemoveAll(file.Path)
265                 if err != nil {
266                         return err
267                 }
268         }
269
270         // Directory: create new directory, also type change to directory
271         if file.Mode.IsDir() && (change.Created || changeType) {
272                 debugf("creating directory")
273                 err := os.Mkdir(file.Path, 0700)
274                 if err != nil {
275                         return err
276                 }
277                 // We must be careful not to chmod arbitrary files. If the
278                 // target directory is writable then it might have changed to
279                 // a symlink at this point. There's no lchmod so open the
280                 // directory.
281                 debugf("chmodding %s", file.Mode)
282                 dh, err := OpenFileNoFollow(file.Path)
283                 if err != nil {
284                         return err
285                 }
286                 err = dh.Chmod(file.Mode)
287                 if err != nil {
288                         dh.Close()
289                         return err
290                 }
291                 // Less restrictive access is not relevant here because there
292                 // are no files present yet.
293                 debugf("chowning %d/%d", file.Uid, file.Gid)
294                 err = dh.Chown(file.Uid, file.Gid)
295                 if err != nil {
296                         dh.Close()
297                         return err
298                 }
299                 dh.Close()
300                 return nil
301         }
302         // Directory: changed permission or user/group
303         if file.Mode.IsDir() {
304                 // We don't know if the new permission or if the new
305                 // user/group is more restrictive (e.g. root:root 0750 ->
306                 // user:group 0700; applying group first gives group
307                 // unexpected access). To prevent a short window where the
308                 // access might be too lax we temporarily deny all access.
309                 if changePerm && changeUserOrGroup {
310                         // Only drop group and other permission because user
311                         // has access anyway (either before or after the
312                         // change). This also prevents temporary errors during
313                         // the error when the user tries to access this
314                         // directory (access for the group will fail though).
315                         mode := change.Old.Mode & fs.ModePerm & 0700
316                         debugf("chmodding %#o (temporary)", mode)
317                         err := oldFh.Chmod(mode)
318                         if err != nil {
319                                 return err
320                         }
321                 }
322                 if changeUserOrGroup {
323                         debugf("chowning %d/%d", file.Uid, file.Gid)
324                         err := oldFh.Chown(file.Uid, file.Gid)
325                         if err != nil {
326                                 return err
327                         }
328                 }
329                 if changePerm {
330                         debugf("chmodding %s", file.Mode)
331                         err := oldFh.Chmod(file.Mode)
332                         if err != nil {
333                                 return err
334                         }
335                 }
336                 return nil
337         }
338
339         dir := filepath.Dir(file.Path)
340         // Create hidden file which should be ignored by most other tools and
341         // thus not affect anything during creation
342         base := "." + filepath.Base(file.Path)
343
344         var tmpPath string
345         switch file.Mode.Type() {
346         case 0: // regular file
347                 debugf("creating temporary file %q",
348                         filepath.Join(dir, base+"*"))
349                 tmpPath, err = WriteTemp(dir, base, file.Data,
350                         file.Uid, file.Gid, file.Mode)
351                 if err != nil {
352                         return err
353                 }
354
355         case fs.ModeSymlink:
356                 i := 0
357         retry:
358                 // Similar to os.CreateTemp() but for symlinks which we cannot
359                 // open as file
360                 tmpPath = filepath.Join(dir,
361                         base+strconv.Itoa(rand.Int()))
362                 debugf("creating temporary symlink %q", tmpPath)
363                 err := os.Symlink(string(file.Data), tmpPath)
364                 if err != nil {
365                         if os.IsExist(err) && i < 10000 {
366                                 i++
367                                 goto retry
368                         }
369                         return err
370                 }
371                 err = os.Lchown(tmpPath, file.Uid, file.Gid)
372                 if err != nil {
373                         os.Remove(tmpPath)
374                         return err
375                 }
376                 // Permissions are irrelevant for symlinks (on most systems)
377
378         default:
379                 panic(fmt.Sprintf("invalid file type %s", file.Mode))
380         }
381
382         debugf("renaming %q", tmpPath)
383         err = os.Rename(tmpPath, file.Path)
384         if err != nil {
385                 os.Remove(tmpPath)
386                 return err
387         }
388         err = SyncPath(dir)
389         if err != nil {
390                 return err
391         }
392
393         return nil
394 }
395
396 func (s *Sync) fileResolveIds(file *safcm.File) error {
397         if file.User != "" && file.Uid != -1 {
398                 return fmt.Errorf("cannot set both User (%q) and Uid (%d)",
399                         file.User, file.Uid)
400         }
401         if file.Group != "" && file.Gid != -1 {
402                 return fmt.Errorf("cannot set both Group (%q) and Gid (%d)",
403                         file.Group, file.Gid)
404         }
405
406         if file.User == "" && file.Uid == -1 {
407                 file.User = s.defaultUser
408         }
409         if file.User != "" {
410                 x, err := user.Lookup(file.User)
411                 if err != nil {
412                         return err
413                 }
414                 id, err := strconv.Atoi(x.Uid)
415                 if err != nil {
416                         return err
417                 }
418                 file.Uid = id
419         }
420
421         if file.Group == "" && file.Gid == -1 {
422                 file.Group = s.defaultGroup
423         }
424         if file.Group != "" {
425                 x, err := user.LookupGroup(file.Group)
426                 if err != nil {
427                         return err
428                 }
429                 id, err := strconv.Atoi(x.Gid)
430                 if err != nil {
431                         return err
432                 }
433                 file.Gid = id
434         }
435
436         return nil
437 }
438
439 func resolveIdsToNames(uid, gid int) (string, string, error) {
440         u, err := user.LookupId(strconv.Itoa(uid))
441         if err != nil {
442                 return "", "", err
443         }
444         g, err := user.LookupGroupId(strconv.Itoa(gid))
445         if err != nil {
446                 return "", "", err
447         }
448         return u.Username, g.Name, nil
449 }
450
451 func diffData(oldData []byte, newData []byte) (string, error) {
452         oldBin := !strings.HasPrefix(http.DetectContentType(oldData), "text/")
453         newBin := !strings.HasPrefix(http.DetectContentType(newData), "text/")
454         if oldBin && newBin {
455                 return fmt.Sprintf("Binary files differ (%d -> %d bytes), "+
456                         "cannot show diff", len(oldData), len(newData)), nil
457         }
458         if oldBin {
459                 oldData = []byte(fmt.Sprintf("<binary content, %d bytes>\n",
460                         len(oldData)))
461         }
462         if newBin {
463                 newData = []byte(fmt.Sprintf("<binary content, %d bytes>\n",
464                         len(newData)))
465         }
466
467         // TODO: difflib shows empty context lines at the end of the file
468         // which should not be there
469         // TODO: difflib has issues with missing newlines in either side
470         result, err := difflib.GetUnifiedDiffString(difflib.LineDiffParams{
471                 A:       difflib.SplitLines(string(oldData)),
472                 B:       difflib.SplitLines(string(newData)),
473                 Context: 3,
474         })
475         if err != nil {
476                 return "", err
477         }
478         return result, nil
479 }
480
481 func OpenFileNoFollow(path string) (*os.File, error) {
482         return os.OpenFile(path,
483                 // O_NOFOLLOW prevents symlink attacks
484                 // O_NONBLOCK is necessary to prevent blocking on FIFOs
485                 os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
486 }
487
488 func WriteTemp(dir, base string, data []byte, uid, gid int, mode fs.FileMode) (
489         string, error) {
490
491         fh, err := os.CreateTemp(dir, base)
492         if err != nil {
493                 return "", err
494         }
495         tmpPath := fh.Name()
496
497         _, err = fh.Write(data)
498         if err != nil {
499                 fh.Close()
500                 os.Remove(tmpPath)
501                 return "", err
502         }
503         // CreateTemp() creates the file with 0600
504         err = fh.Chown(uid, gid)
505         if err != nil {
506                 fh.Close()
507                 os.Remove(tmpPath)
508                 return "", err
509         }
510         err = fh.Chmod(mode)
511         if err != nil {
512                 fh.Close()
513                 os.Remove(tmpPath)
514                 return "", err
515         }
516         err = fh.Sync()
517         if err != nil {
518                 fh.Close()
519                 os.Remove(tmpPath)
520                 return "", err
521         }
522         err = fh.Close()
523         if err != nil {
524                 fh.Close()
525                 os.Remove(tmpPath)
526                 return "", err
527         }
528
529         return tmpPath, nil
530 }
531
532 // SyncPath syncs path, which should be a directory. To guarantee durability
533 // it must be called on a parent directory after adding, renaming or removing
534 // files therein.
535 //
536 // Calling sync on the files itself is not enough according to POSIX; man 2
537 // fsync: "Calling fsync() does not necessarily ensure that the entry in the
538 // directory containing the file has also reached disk. For that an explicit
539 // fsync() on a file descriptor for the directory is also needed."
540 func SyncPath(path string) error {
541         x, err := os.Open(path)
542         if err != nil {
543                 return err
544         }
545         err = x.Sync()
546         closeErr := x.Close()
547         if err != nil {
548                 return err
549         }
550         return closeErr
551 }