]> ruderich.org/simon Gitweb - safcm/safcm.git/blob - cmd/safcm-remote/sync/files.go
sync: refactor temporary file creation into WriteTemp()
[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                 tmpPath, err = WriteTemp(dir, base, file.Data,
341                         file.Uid, file.Gid, file.Mode)
342                 if err != nil {
343                         return err
344                 }
345
346         case fs.ModeSymlink:
347                 i := 0
348         retry:
349                 // Similar to os.CreateTemp() but for symlinks which we cannot
350                 // open as file
351                 tmpPath = filepath.Join(dir,
352                         base+strconv.Itoa(rand.Int()))
353                 debugf("creating temporary symlink %q", tmpPath)
354                 err := os.Symlink(string(file.Data), tmpPath)
355                 if err != nil {
356                         if os.IsExist(err) && i < 10000 {
357                                 i++
358                                 goto retry
359                         }
360                         return err
361                 }
362                 err = os.Lchown(tmpPath, file.Uid, file.Gid)
363                 if err != nil {
364                         os.Remove(tmpPath)
365                         return err
366                 }
367                 // Permissions are irrelevant for symlinks
368
369         default:
370                 panic(fmt.Sprintf("invalid file type %s", file.Mode))
371         }
372
373         debugf("renaming %q", tmpPath)
374         err = os.Rename(tmpPath, file.Path)
375         if err != nil {
376                 os.Remove(tmpPath)
377                 return err
378         }
379         err = syncPath(dir)
380         if err != nil {
381                 return err
382         }
383
384         return nil
385 }
386
387 func (s *Sync) fileResolveIds(file *safcm.File) error {
388         if file.User != "" && file.Uid != -1 {
389                 return fmt.Errorf("cannot set both User (%q) and Uid (%d)",
390                         file.User, file.Uid)
391         }
392         if file.Group != "" && file.Gid != -1 {
393                 return fmt.Errorf("cannot set both Group (%q) and Gid (%d)",
394                         file.Group, file.Gid)
395         }
396
397         if file.User == "" && file.Uid == -1 {
398                 file.User = s.defaultUser
399         }
400         if file.User != "" {
401                 x, err := user.Lookup(file.User)
402                 if err != nil {
403                         return err
404                 }
405                 id, err := strconv.Atoi(x.Uid)
406                 if err != nil {
407                         return err
408                 }
409                 file.Uid = id
410         }
411
412         if file.Group == "" && file.Gid == -1 {
413                 file.Group = s.defaultGroup
414         }
415         if file.Group != "" {
416                 x, err := user.LookupGroup(file.Group)
417                 if err != nil {
418                         return err
419                 }
420                 id, err := strconv.Atoi(x.Gid)
421                 if err != nil {
422                         return err
423                 }
424                 file.Gid = id
425         }
426
427         return nil
428 }
429
430 func resolveIdsToNames(uid, gid int) (string, string, error) {
431         u, err := user.LookupId(strconv.Itoa(uid))
432         if err != nil {
433                 return "", "", err
434         }
435         g, err := user.LookupGroupId(strconv.Itoa(gid))
436         if err != nil {
437                 return "", "", err
438         }
439         return u.Username, g.Name, nil
440 }
441
442 func diffData(oldData []byte, newData []byte) (string, error) {
443         oldBin := !strings.HasPrefix(http.DetectContentType(oldData), "text/")
444         newBin := !strings.HasPrefix(http.DetectContentType(newData), "text/")
445         if oldBin && newBin {
446                 return "Binary files differ, cannot show diff", nil
447         }
448         if oldBin {
449                 oldData = []byte("<binary content>\n")
450         }
451         if newBin {
452                 newData = []byte("<binary content>\n")
453         }
454
455         // TODO: difflib shows empty context lines at the end of the file
456         // which should not be there
457         // TODO: difflib has issues with missing newlines in either side
458         result, err := difflib.GetUnifiedDiffString(difflib.LineDiffParams{
459                 A:       difflib.SplitLines(string(oldData)),
460                 B:       difflib.SplitLines(string(newData)),
461                 Context: 3,
462         })
463         if err != nil {
464                 return "", err
465         }
466         return result, nil
467 }
468
469 func OpenFileNoFollow(path string) (*os.File, error) {
470         return os.OpenFile(path,
471                 // O_NOFOLLOW prevents symlink attacks
472                 // O_NONBLOCK is necessary to prevent blocking on FIFOs
473                 os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
474 }
475
476 func WriteTemp(dir, base string, data []byte, uid, gid int, mode fs.FileMode) (
477         string, error) {
478
479         fh, err := os.CreateTemp(dir, base)
480         if err != nil {
481                 return "", err
482         }
483         tmpPath := fh.Name()
484
485         _, err = fh.Write(data)
486         if err != nil {
487                 fh.Close()
488                 os.Remove(tmpPath)
489                 return "", err
490         }
491         // CreateTemp() creates the file with 0600
492         err = fh.Chown(uid, gid)
493         if err != nil {
494                 fh.Close()
495                 os.Remove(tmpPath)
496                 return "", err
497         }
498         err = fh.Chmod(mode)
499         if err != nil {
500                 fh.Close()
501                 os.Remove(tmpPath)
502                 return "", err
503         }
504         err = fh.Sync()
505         if err != nil {
506                 fh.Close()
507                 os.Remove(tmpPath)
508                 return "", err
509         }
510         err = fh.Close()
511         if err != nil {
512                 fh.Close()
513                 os.Remove(tmpPath)
514                 return "", err
515         }
516
517         return tmpPath, nil
518 }
519
520 // syncPath syncs path, which should be a directory. To guarantee durability
521 // it must be called on a parent directory after adding, renaming or removing
522 // files therein.
523 //
524 // Calling sync on the files itself is not enough according to POSIX; man 2
525 // fsync: "Calling fsync() does not necessarily ensure that the entry in the
526 // directory containing the file has also reached disk. For that an explicit
527 // fsync() on a file descriptor for the directory is also needed."
528 func syncPath(path string) error {
529         x, err := os.Open(path)
530         if err != nil {
531                 return err
532         }
533         err = x.Sync()
534         closeErr := x.Close()
535         if err != nil {
536                 return err
537         }
538         return closeErr
539 }