]> ruderich.org/simon Gitweb - safcm/safcm.git/blob - cmd/safcm-remote/sync/files.go
sync: remove duplication when setting path to temporary file
[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 {
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 != file.Mode {
153                         if change.Old.Mode.Type() != file.Mode.Type() {
154                                 changeType = true
155                                 debugf("type differs %s -> %s",
156                                         change.Old.Mode.Type(),
157                                         file.Mode.Type())
158                         } else {
159                                 // Be careful with .Perm() which does not
160                                 // contain the setuid/setgid/sticky bits!
161                                 changePerm = true
162                                 debugf("permission differs %s -> %s",
163                                         change.Old.Mode, file.Mode)
164                         }
165                 }
166
167                 // Compare user/group
168                 x, ok := oldStat.Sys().(*syscall.Stat_t)
169                 if !ok {
170                         return fmt.Errorf("unsupported Stat().Sys()")
171                 }
172                 change.Old.Uid = int(x.Uid)
173                 change.Old.Gid = int(x.Gid)
174                 if change.Old.Uid != file.Uid || change.Old.Gid != file.Gid {
175                         changeUserOrGroup = true
176                         debugf("uid/gid differs %d/%d -> %d/%d",
177                                 change.Old.Uid, change.Old.Gid,
178                                 file.Uid, file.Gid)
179                 }
180                 u, g, err := resolveIdsToNames(change.Old.Uid, change.Old.Gid)
181                 // Errors are not relevant as this is only used to report the
182                 // change. If the user/group no longer exits only the ids will
183                 // be reported.
184                 if err == nil {
185                         change.Old.User = u
186                         change.Old.Group = g
187                 }
188
189                 // Compare file content (if possible)
190                 switch change.Old.Mode.Type() {
191                 case 0: // regular file
192                         x, err := io.ReadAll(oldFh)
193                         if err != nil {
194                                 return fmt.Errorf("reading old content: %v",
195                                         err)
196                         }
197                         oldData = x
198                 case fs.ModeSymlink:
199                         x, err := os.Readlink(file.Path)
200                         if err != nil {
201                                 return fmt.Errorf("reading old content: %v",
202                                         err)
203                         }
204                         oldData = []byte(x)
205                 }
206                 if !changeType && file.Mode.Type() != fs.ModeDir {
207                         if !bytes.Equal(oldData, file.Data) {
208                                 changeData = true
209                                 debugf("content differs")
210                         }
211                 }
212         }
213         oldStat = nil // prevent accidental use
214
215         // No changes
216         if !change.Created && !changeType &&
217                 !changePerm && !changeUserOrGroup &&
218                 !changeData {
219                 debugf("unchanged")
220                 return nil
221         }
222         *changed = true
223
224         // Don't show a diff with the full content for newly created files or
225         // on type changes. This is just noise for the user as the new file
226         // content is obvious. But we always want to see a diff when files are
227         // replaced because this destroys data.
228         if !change.Created &&
229                 (change.Old.Mode.Type() == 0 ||
230                         change.Old.Mode.Type() == fs.ModeSymlink) {
231                 change.DataDiff, err = diffData(oldData, file.Data)
232                 if err != nil {
233                         return err
234                 }
235         }
236
237         // Add change here so it is stored even when applying it fails. This
238         // way the user knows exactly what was attempted.
239         s.resp.FileChanges = append(s.resp.FileChanges, change)
240
241         if change.Created {
242                 verbosef("creating")
243         } else {
244                 verbosef("updating")
245         }
246
247         if s.req.DryRun {
248                 debugf("dry-run, skipping changes")
249                 return nil
250         }
251
252         // We cannot rename over directories and vice versa
253         if changeType && (change.Old.Mode.IsDir() || file.Mode.IsDir()) {
254                 debugf("removing (due to type change)")
255                 err := os.RemoveAll(file.Path)
256                 if err != nil {
257                         return err
258                 }
259         }
260
261         // Directory: create new directory (also type change to directory)
262         if file.Mode.IsDir() && (change.Created || changeType) {
263                 debugf("creating directory")
264                 err := os.Mkdir(file.Path, 0700)
265                 if err != nil {
266                         return err
267                 }
268                 // We must be careful not to chmod arbitrary files. If the
269                 // target directory is writable then it might have changed to
270                 // a symlink at this point. There's no lchmod so open the
271                 // directory.
272                 debugf("chmodding %s", file.Mode)
273                 dh, err := OpenFileNoFollow(file.Path)
274                 if err != nil {
275                         return err
276                 }
277                 err = dh.Chmod(file.Mode)
278                 if err != nil {
279                         dh.Close()
280                         return err
281                 }
282                 // Less restrictive access is not relevant here because there
283                 // are no files present yet.
284                 debugf("chowning %d/%d", file.Uid, file.Gid)
285                 err = dh.Chown(file.Uid, file.Gid)
286                 if err != nil {
287                         dh.Close()
288                         return err
289                 }
290                 dh.Close()
291                 return nil
292         }
293         // Directory: changed permission or user/group
294         if file.Mode.IsDir() {
295                 // We don't know if the new permission or if the new
296                 // user/group is more restrictive (e.g. root:root 0750 ->
297                 // user:group 0700; applying group first gives group
298                 // unexpected access). To prevent a short window where the
299                 // access might be too lax we temporarily deny all access.
300                 if changePerm && changeUserOrGroup {
301                         // Only drop group and other permission because user
302                         // has access anyway (either before or after the
303                         // change). This also prevents temporary errors during
304                         // the error when the user tries to access this
305                         // directory (access for the group will fail though).
306                         mode := change.Old.Mode & fs.ModePerm & 0700
307                         debugf("chmodding %#o (temporary)", mode)
308                         err := oldFh.Chmod(mode)
309                         if err != nil {
310                                 return err
311                         }
312                 }
313                 if changeUserOrGroup {
314                         debugf("chowning %d/%d", file.Uid, file.Gid)
315                         err := oldFh.Chown(file.Uid, file.Gid)
316                         if err != nil {
317                                 return err
318                         }
319                 }
320                 if changePerm {
321                         debugf("chmodding %s", file.Mode)
322                         err := oldFh.Chmod(file.Mode)
323                         if err != nil {
324                                 return err
325                         }
326                 }
327                 return nil
328         }
329
330         dir := filepath.Dir(file.Path)
331         // Create hidden file which should be ignored by most other tools and
332         // thus not affect anything during creation
333         base := "." + filepath.Base(file.Path)
334
335         var tmpPath string
336         switch file.Mode.Type() {
337         case 0: // regular file
338                 debugf("creating temporary file %q",
339                         filepath.Join(dir, base+"*"))
340                 newFh, err := os.CreateTemp(dir, base)
341                 if err != nil {
342                         return err
343                 }
344                 tmpPath = newFh.Name()
345
346                 _, err = newFh.Write(file.Data)
347                 if err != nil {
348                         newFh.Close()
349                         os.Remove(tmpPath)
350                         return err
351                 }
352                 // CreateTemp() creates the file with 0600
353                 err = newFh.Chown(file.Uid, file.Gid)
354                 if err != nil {
355                         newFh.Close()
356                         os.Remove(tmpPath)
357                         return err
358                 }
359                 err = newFh.Chmod(file.Mode)
360                 if err != nil {
361                         newFh.Close()
362                         os.Remove(tmpPath)
363                         return err
364                 }
365                 err = newFh.Sync()
366                 if err != nil {
367                         newFh.Close()
368                         os.Remove(tmpPath)
369                         return err
370                 }
371                 err = newFh.Close()
372                 if err != nil {
373                         newFh.Close()
374                         os.Remove(tmpPath)
375                         return err
376                 }
377
378         case fs.ModeSymlink:
379                 i := 0
380         retry:
381                 // Similar to os.CreateTemp() but for symlinks which we cannot
382                 // open as file
383                 tmpPath = filepath.Join(dir,
384                         base+strconv.Itoa(rand.Int()))
385                 debugf("creating temporary symlink %q", tmpPath)
386                 err := os.Symlink(string(file.Data), tmpPath)
387                 if err != nil {
388                         if os.IsExist(err) && i < 10000 {
389                                 i++
390                                 goto retry
391                         }
392                         return err
393                 }
394                 err = os.Lchown(tmpPath, file.Uid, file.Gid)
395                 if err != nil {
396                         os.Remove(tmpPath)
397                         return err
398                 }
399                 // Permissions are irrelevant for symlinks
400
401         default:
402                 panic(fmt.Sprintf("invalid file type %s", file.Mode))
403         }
404
405         debugf("renaming %q", tmpPath)
406         err = os.Rename(tmpPath, file.Path)
407         if err != nil {
408                 os.Remove(tmpPath)
409                 return err
410         }
411         err = syncPath(dir)
412         if err != nil {
413                 return err
414         }
415
416         return nil
417 }
418
419 func (s *Sync) fileResolveIds(file *safcm.File) error {
420         if file.User != "" && file.Uid != -1 {
421                 return fmt.Errorf("cannot set both User (%q) and Uid (%d)",
422                         file.User, file.Uid)
423         }
424         if file.Group != "" && file.Gid != -1 {
425                 return fmt.Errorf("cannot set both Group (%q) and Gid (%d)",
426                         file.Group, file.Gid)
427         }
428
429         if file.User == "" && file.Uid == -1 {
430                 file.User = s.defaultUser
431         }
432         if file.User != "" {
433                 x, err := user.Lookup(file.User)
434                 if err != nil {
435                         return err
436                 }
437                 id, err := strconv.Atoi(x.Uid)
438                 if err != nil {
439                         return err
440                 }
441                 file.Uid = id
442         }
443
444         if file.Group == "" && file.Gid == -1 {
445                 file.Group = s.defaultGroup
446         }
447         if file.Group != "" {
448                 x, err := user.LookupGroup(file.Group)
449                 if err != nil {
450                         return err
451                 }
452                 id, err := strconv.Atoi(x.Gid)
453                 if err != nil {
454                         return err
455                 }
456                 file.Gid = id
457         }
458
459         return nil
460 }
461
462 func resolveIdsToNames(uid, gid int) (string, string, error) {
463         u, err := user.LookupId(strconv.Itoa(uid))
464         if err != nil {
465                 return "", "", err
466         }
467         g, err := user.LookupGroupId(strconv.Itoa(gid))
468         if err != nil {
469                 return "", "", err
470         }
471         return u.Username, g.Name, nil
472 }
473
474 func diffData(oldData []byte, newData []byte) (string, error) {
475         oldBin := !strings.HasPrefix(http.DetectContentType(oldData), "text/")
476         newBin := !strings.HasPrefix(http.DetectContentType(newData), "text/")
477         if oldBin && newBin {
478                 return "Binary files differ, cannot show diff", nil
479         }
480         if oldBin {
481                 oldData = []byte("<binary content>\n")
482         }
483         if newBin {
484                 newData = []byte("<binary content>\n")
485         }
486
487         // TODO: difflib shows empty context lines at the end of the file
488         // which should not be there
489         // TODO: difflib has issues with missing newlines in either side
490         result, err := difflib.GetUnifiedDiffString(difflib.LineDiffParams{
491                 A:       difflib.SplitLines(string(oldData)),
492                 B:       difflib.SplitLines(string(newData)),
493                 Context: 3,
494         })
495         if err != nil {
496                 return "", err
497         }
498         return result, nil
499 }
500
501 func OpenFileNoFollow(path string) (*os.File, error) {
502         return os.OpenFile(path,
503                 // O_NOFOLLOW prevents symlink attacks
504                 // O_NONBLOCK is necessary to prevent blocking on FIFOs
505                 os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
506 }
507
508 // syncPath syncs path, which should be a directory. To guarantee durability
509 // it must be called on a parent directory after adding, renaming or removing
510 // files therein.
511 //
512 // Calling sync on the files itself is not enough according to POSIX; man 2
513 // fsync: "Calling fsync() does not necessarily ensure that the entry in the
514 // directory containing the file has also reached disk. For that an explicit
515 // fsync() on a file descriptor for the directory is also needed."
516 func syncPath(path string) error {
517         x, err := os.Open(path)
518         if err != nil {
519                 return err
520         }
521         err = x.Sync()
522         closeErr := x.Close()
523         if err != nil {
524                 return err
525         }
526         return closeErr
527 }