From: Simon Ruderich Date: Sat, 25 Oct 2025 08:20:14 +0000 (+0200) Subject: remote, frontend: support removing paths X-Git-Url: https://ruderich.org/simon/gitweb/?a=commitdiff_plain;h=d53b5f120fdbe793a6f2e2ca498ebb0763703bbf;p=safcm%2Fsafcm.git remote, frontend: support removing paths Not used by safcm but for other programs using safcm as library. --- diff --git a/frontend/changes.go b/frontend/changes.go index 3388b32..228716e 100644 --- a/frontend/changes.go +++ b/frontend/changes.go @@ -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", diff --git a/frontend/changes_test.go b/frontend/changes_test.go index b55d7dc..fa2c28e 100644 --- a/frontend/changes_test.go +++ b/frontend/changes_test.go @@ -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", }, { diff --git a/remote/sync/files.go b/remote/sync/files.go index 3363196..3f0f196 100644 --- a/remote/sync/files.go +++ b/remote/sync/files.go @@ -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 diff --git a/remote/sync/files_test.go b/remote/sync/files_test.go index 7a1046e..51b9c56 100644 --- a/remote/sync/files_test.go +++ b/remote/sync/files_test.go @@ -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{}, diff --git a/types.go b/types.go index 9f50100..173cd0b 100644 --- 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