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