]> ruderich.org/simon Gitweb - safcm/safcm.git/commitdiff
remote, frontend: support removing paths
authorSimon Ruderich <simon@ruderich.org>
Sat, 25 Oct 2025 08:20:14 +0000 (10:20 +0200)
committerSimon Ruderich <simon@ruderich.org>
Sat, 25 Oct 2025 08:20:14 +0000 (10:20 +0200)
Not used by safcm but for other programs using safcm as library.

frontend/changes.go
frontend/changes_test.go
remote/sync/files.go
remote/sync/files_test.go
types.go

index 3388b3295115c23c978a549b18fd0b57f3319c45..228716ecfe4004ef46fa212f1734fea7b1b7380a 100644 (file)
@@ -72,6 +72,13 @@ func (c *Changes) FormatFileChanges(changes []safcm.FileChange) string {
                                FormatFileUserGroup(x.New),
                                FormatFilePerm(x.New),
                        )
+               } else if x.Removed {
+                       info = append(info,
+                               ColorString(c.IsTTY, ColorRed, "removed"),
+                               FormatFileType(x.Old),
+                               FormatFileUserGroup(x.Old),
+                               FormatFilePerm(x.Old),
+                       )
                } else {
                        if x.Old.Mode.Type() != x.New.Mode.Type() {
                                info = append(info, fmt.Sprintf("%s -> %s",
index b55d7dc9b67fc48b4fb8971ce883270655b79c57..fa2c28e4b35ba0dfb12bb168fe4208fd2c69289e 100644 (file)
@@ -180,6 +180,47 @@ func TestFormatFileChanges(t *testing.T) {
                                Gid:   2000,
                        },
                },
+               {
+                       Path:    "removed: file",
+                       Removed: true,
+                       Old: safcm.FileChangeInfo{
+                               Mode:  0644,
+                               User:  "user",
+                               Uid:   1000,
+                               Group: "group",
+                               Gid:   2000,
+                       },
+                       DataDiff: `@@ -1,2 +1 @@
+-content
+`,
+               },
+               {
+                       Path:    "removed: link",
+                       Removed: true,
+                       Old: safcm.FileChangeInfo{
+                               Mode:  fs.ModeSymlink | 0777,
+                               User:  "user",
+                               Uid:   1000,
+                               Group: "group",
+                               Gid:   2000,
+                       },
+                       DataDiff: `@@ -1 +1 @@
+-target
++
+`,
+               },
+               {
+                       Path:    "removed: directory",
+                       Removed: true,
+                       Old: safcm.FileChangeInfo{
+                               Mode:  fs.ModeDir | 0755,
+                               User:  "user",
+                               Uid:   1000,
+                               Group: "group",
+                               Gid:   2000,
+                       },
+               },
                {
                        Path: "type change: file -> dir",
                        Old: safcm.FileChangeInfo{
@@ -327,9 +368,18 @@ func TestFormatFileChanges(t *testing.T) {
                        false,
                        false,
                        changes,
-                       `changed 9 file(s):
+                       `changed 12 file(s):
 "created: file": created, file, user(1000) group(2000), 0644
 "created: link": created, symlink, user(1000) group(2000), 0777
+"removed: file": removed, file, user(1000) group(2000), 0644
+   @@ -1,2 +1 @@
+   -content
+    
+"removed: link": removed, symlink, user(1000) group(2000), 0777
+   @@ -1 +1 @@
+   -target
+   +
+"removed: directory": removed, dir, user(1000) group(2000), 0755
 "type change: file -> dir": file -> dir
    @@ -1,2 +1 @@
    -content
@@ -355,7 +405,7 @@ func TestFormatFileChanges(t *testing.T) {
                        false,
                        true,
                        changes,
-                       "changed 9 file(s):\n\x1b[36m\"created: file\"\x1b[0m: \x1b[32mcreated\x1b[0m, file, user(1000) group(2000), 0644\n\x1b[36m\"created: link\"\x1b[0m: \x1b[32mcreated\x1b[0m, symlink, user(1000) group(2000), 0777\n\x1b[36m\"type change: file -> dir\"\x1b[0m: file -> dir\n   @@ -1,2 +1 @@\n\x1b[31m   -content\x1b[0m\n    \n\x1b[36m\"user change\"\x1b[0m: user(1000) group(2000) -> user2(1001) group(2000)\n\x1b[36m\"group change\"\x1b[0m: user(1000) group(2000) -> user(1000) group2(2001)\n\x1b[36m\"mode change\"\x1b[0m: 0755 -> 0750\n\x1b[36m\"mode change (setuid)\"\x1b[0m: 0755 -> 04755\n\x1b[36m\"content change\"\x1b[0m:\n   @@ -1,2 +1,2 @@\n\x1b[31m   -old content\x1b[0m\n\x1b[32m   +content\x1b[0m\n    \n\x1b[36m\"multiple changes\"\x1b[0m: file -> dir, user(1000) group(2000) -> user2(1001) group2(2001), 0644 -> 0755\n   @@ -1,2 +1 @@\n\x1b[31m   -content\x1b[0m\n    \n",
+                       "changed 12 file(s):\n\x1b[36m\"created: file\"\x1b[0m: \x1b[32mcreated\x1b[0m, file, user(1000) group(2000), 0644\n\x1b[36m\"created: link\"\x1b[0m: \x1b[32mcreated\x1b[0m, symlink, user(1000) group(2000), 0777\n\x1b[36m\"removed: file\"\x1b[0m: \x1b[31mremoved\x1b[0m, file, user(1000) group(2000), 0644\n   @@ -1,2 +1 @@\n\x1b[31m   -content\x1b[0m\n    \n\x1b[36m\"removed: link\"\x1b[0m: \x1b[31mremoved\x1b[0m, symlink, user(1000) group(2000), 0777\n   @@ -1 +1 @@\n\x1b[31m   -target\x1b[0m\n\x1b[32m   +\x1b[0m\n\x1b[36m\"removed: directory\"\x1b[0m: \x1b[31mremoved\x1b[0m, dir, user(1000) group(2000), 0755\n\x1b[36m\"type change: file -> dir\"\x1b[0m: file -> dir\n   @@ -1,2 +1 @@\n\x1b[31m   -content\x1b[0m\n    \n\x1b[36m\"user change\"\x1b[0m: user(1000) group(2000) -> user2(1001) group(2000)\n\x1b[36m\"group change\"\x1b[0m: user(1000) group(2000) -> user(1000) group2(2001)\n\x1b[36m\"mode change\"\x1b[0m: 0755 -> 0750\n\x1b[36m\"mode change (setuid)\"\x1b[0m: 0755 -> 04755\n\x1b[36m\"content change\"\x1b[0m:\n   @@ -1,2 +1,2 @@\n\x1b[31m   -old content\x1b[0m\n\x1b[32m   +content\x1b[0m\n    \n\x1b[36m\"multiple changes\"\x1b[0m: file -> dir, user(1000) group(2000) -> user2(1001) group2(2001), 0644 -> 0755\n   @@ -1,2 +1 @@\n\x1b[31m   -content\x1b[0m\n    \n",
                },
 
                {
index 33631964cebbf6af43354bd879d79fd26c4e047f..3f0f19652aed1abe298a850f239fb735691e7643 100644 (file)
@@ -89,20 +89,27 @@ func (s *Sync) syncFile(file *safcm.File, changed *bool) error {
        // only to places which are writable by the user which cannot be
        // prevented.
 
-       err := s.fileResolveIds(file)
-       if err != nil {
-               return err
-       }
-
        change := safcm.FileChange{
                Path: file.Path,
-               New: safcm.FileChangeInfo{
+       }
+       if file.Remove {
+               // Sanity check
+               if file.Data != nil {
+                       return fmt.Errorf(".Remove needs .Data == nil: %v", file)
+               }
+               change.Removed = true
+       } else {
+               err := s.fileResolveIds(file)
+               if err != nil {
+                       return err
+               }
+               change.New = safcm.FileChangeInfo{
                        Mode:  file.Mode,
                        User:  file.User,
                        Uid:   file.Uid,
                        Group: file.Group,
                        Gid:   file.Gid,
-               },
+               }
        }
 
        debugf := func(format string, a ...interface{}) {
@@ -116,6 +123,10 @@ func (s *Sync) syncFile(file *safcm.File, changed *bool) error {
 
        parentFd, baseName, err := OpenParentDirectoryNoSymlinks(file.Path)
        if err != nil {
+               if os.IsNotExist(err) && file.Remove {
+                       debugf("parent not present")
+                       return nil
+               }
                if os.IsNotExist(err) && s.req.DryRun {
                        change.Created = true
                        debugf("will create (parent missing)")
@@ -144,6 +155,10 @@ reopen:
                                goto reopen
                        }
                } else if os.IsNotExist(err) {
+                       if file.Remove {
+                               debugf("not present")
+                               return nil
+                       }
                        change.Created = true
                        debugf("will create")
                } else {
@@ -207,7 +222,7 @@ reopen:
                        // TODO: Add proper support for symlinks on BSD
                        change.Old.Mode |= 0777
                }
-               if change.Old.Mode != file.Mode {
+               if !file.Remove && change.Old.Mode != file.Mode {
                        if change.Old.Mode.Type() != file.Mode.Type() {
                                changeType = true
                                debugf("type differs %s -> %s",
@@ -225,7 +240,8 @@ reopen:
                // Compare user/group
                change.Old.Uid = int(oldStat.Uid)
                change.Old.Gid = int(oldStat.Gid)
-               if change.Old.Uid != file.Uid || change.Old.Gid != file.Gid {
+               if !file.Remove &&
+                       (change.Old.Uid != file.Uid || change.Old.Gid != file.Gid) {
                        changeUserOrGroup = true
                        debugf("uid/gid differs %d/%d -> %d/%d",
                                change.Old.Uid, change.Old.Gid,
@@ -255,7 +271,7 @@ reopen:
                        }
                        oldData = buf[:n]
                }
-               if !changeType && file.Mode.Type() != fs.ModeDir {
+               if !file.Remove && !changeType && file.Mode.Type() != fs.ModeDir {
                        if !bytes.Equal(oldData, file.Data) {
                                changeData = true
                                debugf("content differs")
@@ -264,7 +280,7 @@ reopen:
        }
 
        // No changes
-       if !change.Created && !changeType &&
+       if !file.Remove && !change.Created && !changeType &&
                !changePerm && !changeUserOrGroup &&
                !changeData {
                debugf("unchanged")
@@ -289,7 +305,9 @@ reopen:
        // the user knows exactly what was attempted.
        s.resp.FileChanges = append(s.resp.FileChanges, change)
 
-       if change.Created {
+       if file.Remove {
+               verbosef("removing")
+       } else if change.Created {
                verbosef("creating")
        } else {
                verbosef("updating")
@@ -301,14 +319,19 @@ reopen:
        }
 
        // We cannot rename over directories and vice versa
-       if changeType && (change.Old.Mode.IsDir() || file.Mode.IsDir()) {
-               debugf("removing (due to type change)")
+       if file.Remove ||
+               (changeType && (change.Old.Mode.IsDir() || file.Mode.IsDir())) {
+               if !file.Remove {
+                       debugf("removing (due to type change)")
+               }
                // In the past os.RemoveAll() was used here. However, this is
                // difficult to implement manually with *at syscalls. To keep it
                // simple only permit removing files and empty directories here. This
                // also has the bonus of preventing data loss when (accidentally)
                // replacing a directory tree with a file.
-               const msg = "will not replace non-empty directory, " +
+               const msg1 = "will not remove non-empty directory, " +
+                       "please remove manually"
+               const msg2 = "will not replace non-empty directory, " +
                        "please remove manually"
                err := unix.Unlinkat(parentFd, baseName, 0 /* flags */)
                if err != nil && !os.IsNotExist(err) {
@@ -318,12 +341,21 @@ reopen:
                                if err2 == unix.ENOTDIR {
                                        return err
                                } else if err2 == unix.ENOTEMPTY {
+                                       var msg string
+                                       if file.Remove {
+                                               msg = msg1
+                                       } else {
+                                               msg = msg2
+                                       }
                                        return fmt.Errorf("%s", msg)
                                } else {
                                        return err2
                                }
                        }
                }
+               if file.Remove {
+                       return nil
+               }
        }
 
        // Directory: create new directory, also type change to directory
index 7a1046e5b945ec5f0f9b5e272c63219f45765d50..51b9c5603a2bc7ca558cc64c21c576dfcf35f112 100644 (file)
@@ -1135,6 +1135,149 @@ func TestSyncFile(t *testing.T) {
                        nil,
                },
 
+               {
+                       "file: remove",
+                       safcm.MsgSyncReq{},
+                       &safcm.File{
+                               Path:      "file",
+                               Remove:    true,
+                               OrigGroup: "group",
+                       },
+                       func() {
+                               ft.CreateFile("file", "content\n", 0644)
+                       },
+                       true,
+                       []ft.File{
+                               root,
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path:    "file",
+                                               Removed: true,
+                                               Old: safcm.FileChangeInfo{
+                                                       Mode:  0644,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               DataDiff: `@@ -1,2 +1 @@
+-content
+`,
+                                       },
+                               },
+                       },
+                       []string{
+                               `3: files: "file" (group): removing`,
+                       },
+                       nil,
+               },
+               {
+                       "file: remove (dry-run)",
+                       safcm.MsgSyncReq{
+                               DryRun: true,
+                       },
+                       &safcm.File{
+                               Path:      "file",
+                               Remove:    true,
+                               OrigGroup: "group",
+                       },
+                       func() {
+                               ft.CreateFile("file", "content\n", 0644)
+                       },
+                       true,
+                       []ft.File{
+                               root,
+                               {
+                                       Path: "file",
+                                       Mode: 0644,
+                                       Data: []byte("content\n"),
+                               },
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path:    "file",
+                                               Removed: true,
+                                               Old: safcm.FileChangeInfo{
+                                                       Mode:  0644,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               DataDiff: `@@ -1,2 +1 @@
+-content
+`,
+                                       },
+                               },
+                       },
+                       []string{
+                               `3: files: "file" (group): removing`,
+                               `4: files: "file" (group): dry-run, skipping changes`,
+                       },
+                       nil,
+               },
+               {
+                       "file: remove (not present)",
+                       safcm.MsgSyncReq{},
+                       &safcm.File{
+                               Path:      "file",
+                               Remove:    true,
+                               OrigGroup: "group",
+                       },
+                       nil,
+                       false,
+                       []ft.File{
+                               root,
+                       },
+                       safcm.MsgSyncResp{},
+                       []string{
+                               `4: files: "file" (group): not present`,
+                       },
+                       nil,
+               },
+               {
+                       "file: remove (parent not present)",
+                       safcm.MsgSyncReq{},
+                       &safcm.File{
+                               Path:      "dir/file",
+                               Remove:    true,
+                               OrigGroup: "group",
+                       },
+                       nil,
+                       false,
+                       []ft.File{
+                               root,
+                       },
+                       safcm.MsgSyncResp{},
+                       []string{
+                               `4: files: "dir/file" (group): parent not present`,
+                       },
+                       nil,
+               },
+               {
+                       "file: remove (.Data set)",
+                       safcm.MsgSyncReq{},
+                       &safcm.File{
+                               Path:      "file",
+                               Remove:    true,
+                               OrigGroup: "group",
+                               Data:      []byte("invalid"),
+                       },
+                       nil,
+                       false,
+                       []ft.File{
+                               root,
+                       },
+                       safcm.MsgSyncResp{},
+                       nil,
+                       fmt.Errorf(".Remove needs .Data == nil: &{group file true ----------  0  0 [105 110 118 97 108 105 100] []}"),
+               },
+
                {
                        "file: permission",
                        safcm.MsgSyncReq{},
@@ -1412,6 +1555,93 @@ func TestSyncFile(t *testing.T) {
                        nil,
                },
 
+               {
+                       "symlink: remove",
+                       safcm.MsgSyncReq{},
+                       &safcm.File{
+                               Path:      "link",
+                               Remove:    true,
+                               OrigGroup: "group",
+                       },
+                       func() {
+                               ft.CreateSymlink("link", "target")
+                       },
+                       true,
+                       []ft.File{
+                               root,
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path:    "link",
+                                               Removed: true,
+                                               Old: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeSymlink | 0777,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               DataDiff: `@@ -1 +1 @@
+-target
++
+`,
+                                       },
+                               },
+                       },
+                       []string{
+                               `3: files: "link" (group): removing`,
+                       },
+                       nil,
+               },
+               {
+                       "symlink: remove (dry-run)",
+                       safcm.MsgSyncReq{
+                               DryRun: true,
+                       },
+                       &safcm.File{
+                               Path:      "link",
+                               Remove:    true,
+                               OrigGroup: "group",
+                       },
+                       func() {
+                               ft.CreateSymlink("link", "target")
+                       },
+                       true,
+                       []ft.File{
+                               root,
+                               {
+                                       Path: "link",
+                                       Mode: fs.ModeSymlink | 0777,
+                                       Data: []byte("target"),
+                               },
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path:    "link",
+                                               Removed: true,
+                                               Old: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeSymlink | 0777,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               DataDiff: `@@ -1 +1 @@
+-target
++
+`,
+                                       },
+                               },
+                       },
+                       []string{
+                               `3: files: "link" (group): removing`,
+                               `4: files: "link" (group): dry-run, skipping changes`,
+                       },
+                       nil,
+               },
+
                {
                        "symlink: content",
                        safcm.MsgSyncReq{},
@@ -1617,6 +1847,129 @@ func TestSyncFile(t *testing.T) {
                        nil,
                },
 
+               {
+                       "directory: remove",
+                       safcm.MsgSyncReq{},
+                       &safcm.File{
+                               Path:      "dir",
+                               Remove:    true,
+                               OrigGroup: "group",
+                       },
+                       func() {
+                               ft.CreateDirectory("dir", 0755)
+                       },
+                       true,
+                       []ft.File{
+                               root,
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path:    "dir",
+                                               Removed: true,
+                                               Old: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeDir | 0755,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                       },
+                               },
+                       },
+                       []string{
+                               `3: files: "dir" (group): removing`,
+                       },
+                       nil,
+               },
+               {
+                       "directory: remove (dry-run)",
+                       safcm.MsgSyncReq{
+                               DryRun: true,
+                       },
+                       &safcm.File{
+                               Path:      "dir",
+                               Remove:    true,
+                               OrigGroup: "group",
+                       },
+                       func() {
+                               ft.CreateDirectory("dir", 0755)
+                       },
+                       true,
+                       []ft.File{
+                               root,
+                               {
+                                       Path: "dir",
+                                       Mode: fs.ModeDir | 0755,
+                               },
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path:    "dir",
+                                               Removed: true,
+                                               Old: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeDir | 0755,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                       },
+                               },
+                       },
+                       []string{
+                               `3: files: "dir" (group): removing`,
+                               `4: files: "dir" (group): dry-run, skipping changes`,
+                       },
+                       nil,
+               },
+               {
+                       "directory: remove (non-empty)",
+                       safcm.MsgSyncReq{},
+                       &safcm.File{
+                               Path:      "dir",
+                               Remove:    true,
+                               OrigGroup: "group",
+                       },
+                       func() {
+                               ft.CreateDirectory("dir", 0755)
+                               ft.CreateFile("dir/file", "content\n", 0644)
+                       },
+                       true,
+                       []ft.File{
+                               root,
+                               {
+                                       Path: "dir",
+                                       Mode: fs.ModeDir | 0755,
+                               },
+                               {
+                                       Path: "dir/file",
+                                       Mode: 0644,
+                                       Data: []byte("content\n"),
+                               },
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path:    "dir",
+                                               Removed: true,
+                                               Old: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeDir | 0755,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                       },
+                               },
+                       },
+                       []string{
+                               `3: files: "dir" (group): removing`,
+                       },
+                       fmt.Errorf("will not remove non-empty directory, please remove manually"),
+               },
+
                {
                        "directory: permission",
                        safcm.MsgSyncReq{},
index 9f5010080827bfe81a8ee226f632e14ad129e5e8..173cd0be25d58a2d7b8361c4be5d4d731333859d 100644 (file)
--- a/types.go
+++ b/types.go
@@ -83,6 +83,8 @@ type File struct {
 
        Path string
 
+       Remove bool
+
        Mode  fs.FileMode
        User  string
        Uid   int //lint:ignore ST1003 UID is too ugly
@@ -102,6 +104,7 @@ type Command struct {
 type FileChange struct {
        Path     string
        Created  bool
+       Removed  bool
        Old      FileChangeInfo
        New      FileChangeInfo
        DataDiff string