diff options
Diffstat (limited to 'internal/admin')
| -rw-r--r-- | internal/admin/api_files.go | 79 | ||||
| -rw-r--r-- | internal/admin/api_helpers.go | 46 | ||||
| -rw-r--r-- | internal/admin/api_rooms.go | 36 | ||||
| -rw-r--r-- | internal/admin/api_rooms_bulk.go | 11 | ||||
| -rw-r--r-- | internal/admin/api_rooms_bulk_delete.go | 12 | ||||
| -rw-r--r-- | internal/admin/fixes_regression_test.go | 49 | ||||
| -rw-r--r-- | internal/admin/server.go | 29 | ||||
| -rw-r--r-- | internal/admin/undo.go | 95 | ||||
| -rw-r--r-- | internal/admin/yaml_util.go | 5 |
9 files changed, 335 insertions, 27 deletions
diff --git a/internal/admin/api_files.go b/internal/admin/api_files.go index d32bda4..2b59526 100644 --- a/internal/admin/api_files.go +++ b/internal/admin/api_files.go @@ -8,6 +8,8 @@ import ( "path/filepath" "strconv" "strings" + + "thehouseoficarus/internal/persistence" ) // resolveDataPath resolves a request-supplied path inside the admin data @@ -93,7 +95,7 @@ func (s *AdminServer) handleFileRename(w http.ResponseWriter, r *http.Request) { return } - if err := os.WriteFile(newAbs, oldContent, 0644); err != nil { + if err := writeFilePreservingMode(newAbs, oldContent); err != nil { failJSON(w, "write error: "+err.Error()) return } @@ -174,6 +176,7 @@ func (s *AdminServer) handleFiles(w http.ResponseWriter, r *http.Request) { failJSON(w, "read error: "+err.Error()) return } + data = redactAccountContent(relPath, data) writeJSON(w, map[string]any{ "name": info.Name(), "content": string(data), @@ -190,6 +193,9 @@ func (s *AdminServer) handleFiles(w http.ResponseWriter, r *http.Request) { failJSON(w, "read body: "+err.Error()) return } + // A redacted account hash is restored from the file on disk before + // writing, so the raw editor can never disclose or overwrite it. + body = restoreAccountSecret(relPath, fullPath, body) var oldContent []byte if existing, err := snapshotFile(fullPath); err == nil { @@ -202,7 +208,7 @@ func (s *AdminServer) handleFiles(w http.ResponseWriter, r *http.Request) { return } - if err := os.WriteFile(fullPath, body, 0644); err != nil { + if err := writeFilePreservingMode(fullPath, body); err != nil { failJSON(w, "write error: "+err.Error()) return } @@ -244,3 +250,72 @@ func (s *AdminServer) handleFiles(w http.ResponseWriter, r *http.Request) { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } + +// writeFilePreservingMode writes data atomically without widening an existing +// file's permissions (persistence.WriteFile preserves the current mode and +// uses 0644 for a new file). +func writeFilePreservingMode(path string, data []byte) error { + return persistence.WriteFile(path, data, 0644) +} + +// isAccountFilePath reports whether a data-relative path is an account file +// whose password hash must not be exposed or overwritten through the raw file +// editor. +func isAccountFilePath(relPath string) bool { + clean := filepath.ToSlash(filepath.Clean(relPath)) + return strings.HasPrefix(clean, "players/accounts/") +} + +const redactedAccountHash = "[redacted]" + +// redactAccountContent replaces the password hash with a placeholder for GET +// responses. The placeholder round-trips through restoreAccountSecret on save. +func redactAccountContent(relPath string, data []byte) []byte { + if !isAccountFilePath(relPath) { + return data + } + m, err := yamlToMap(data) + if err != nil { + return data + } + if _, ok := m["password_hash"]; !ok { + return data + } + m["password_hash"] = redactedAccountHash + out, err := marshalMapYAML(m) + if err != nil { + return data + } + return out +} + +// restoreAccountSecret copies the on-disk password hash back into a submitted +// account file, so a redacted value (or any client-supplied value) can never +// change or blank an existing hash through the raw file editor. +func restoreAccountSecret(relPath, fullPath string, body []byte) []byte { + if !isAccountFilePath(relPath) { + return body + } + existing, err := os.ReadFile(fullPath) + if err != nil { + return body + } + em, err := yamlToMap(existing) + if err != nil { + return body + } + hash, ok := em["password_hash"] + if !ok { + return body + } + bm, err := yamlToMap(body) + if err != nil { + return body + } + bm["password_hash"] = hash + out, err := marshalMapYAML(bm) + if err != nil { + return body + } + return out +} diff --git a/internal/admin/api_helpers.go b/internal/admin/api_helpers.go index 11ad958..37cc4ae 100644 --- a/internal/admin/api_helpers.go +++ b/internal/admin/api_helpers.go @@ -3,10 +3,13 @@ package admin import ( "bytes" "fmt" - "os" + "path/filepath" "strings" "gopkg.in/yaml.v3" + + "thehouseoficarus/internal/persistence" + "thehouseoficarus/internal/world" ) // validEntityID reports whether an entity ID can be safely joined into a @@ -23,6 +26,45 @@ func validEntityID(id string) bool { return true } +// resolveRoomSubdir validates a client-supplied room subdirectory and returns +// the cleaned path relative to <dataDir>/rooms. Absolute paths, ".." escapes, +// drive-like prefixes, and backslashes are rejected; the empty string is the +// rooms root. Room-directory API handlers must route body.Dir/patch.Dir through +// this before joining, mirroring validEntityID/resolveDataPath elsewhere. +func (s *AdminServer) resolveRoomSubdir(dir string) (string, error) { + dir = strings.TrimSpace(dir) + if dir == "" { + return "", nil + } + if filepath.IsAbs(dir) || strings.ContainsRune(dir, '\\') || strings.ContainsRune(dir, 0) { + return "", fmt.Errorf("invalid directory") + } + clean := filepath.Clean(dir) + if clean == "." { + return "", nil + } + if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("directory escapes the rooms tree") + } + base := filepath.Join(s.dataDir, "rooms") + rel, err := filepath.Rel(base, filepath.Join(base, clean)) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("directory escapes the rooms tree") + } + return clean, nil +} + +// validateRoomSchema decodes a rendered room YAML into world.Room before it is +// written, so the editor cannot persist a room the runtime loader would later +// reject (for example a non-numeric exit target). KnownFields(true) also +// catches keys the runtime would silently ignore. +func validateRoomSchema(content []byte) error { + dec := yaml.NewDecoder(bytes.NewReader(content)) + dec.KnownFields(true) + var room world.Room + return dec.Decode(&room) +} + func yamlToMap(data []byte) (map[string]any, error) { var v any if err := yaml.Unmarshal(data, &v); err != nil { @@ -65,7 +107,7 @@ func writeMapAsYAML(path string, m map[string]any) ([]byte, error) { if err != nil { return nil, err } - if err := os.WriteFile(path, content, 0644); err != nil { + if err := persistence.WriteFile(path, content, 0644); err != nil { return nil, err } return content, nil diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go index f9508f6..d84449b 100644 --- a/internal/admin/api_rooms.go +++ b/internal/admin/api_rooms.go @@ -298,9 +298,17 @@ func (s *AdminServer) handleRoomByID(w http.ResponseWriter, r *http.Request) { return } - newContent, writeErr := writeMapAsYAML(path, m) - if writeErr != nil { - failJSON(w, fmt.Sprintf("write room: %v", writeErr)) + newContent, marshalErr := marshalMapYAML(m) + if marshalErr != nil { + failJSON(w, fmt.Sprintf("marshal room: %v", marshalErr)) + return + } + if err := validateRoomSchema(newContent); err != nil { + writeJSONError(w, "room failed schema validation: "+err.Error(), http.StatusBadRequest) + return + } + if err := writeFilePreservingMode(path, newContent); err != nil { + failJSON(w, fmt.Sprintf("write room: %v", err)) return } @@ -455,14 +463,19 @@ func (s *AdminServer) handleRenameRoomDir(w http.ResponseWriter, r *http.Request failJSON(w, "cannot rename root") return } + dir, err := s.resolveRoomSubdir(body.Dir) + if err != nil || dir == "" { + failJSON(w, "invalid directory") + return + } newName := strings.TrimSpace(body.NewName) if newName == "" || strings.ContainsAny(newName, "/\\") || newName == "." || newName == ".." { failJSON(w, "invalid directory name") return } base := filepath.Join(s.dataDir, "rooms") - parent := filepath.Dir(body.Dir) - oldPath := filepath.Join(base, body.Dir) + parent := filepath.Dir(dir) + oldPath := filepath.Join(base, dir) newRel := filepath.Join(parent, newName) newPath := filepath.Join(base, newRel) if _, err := os.Stat(oldPath); os.IsNotExist(err) { @@ -825,7 +838,11 @@ func (s *AdminServer) handleRoomMove(w http.ResponseWriter, r *http.Request) { failJSON(w, "room not found") return } - dir := strings.TrimSpace(body.Dir) + dir, err := s.resolveRoomSubdir(body.Dir) + if err != nil { + failJSON(w, err.Error()) + return + } destDir := filepath.Join(s.dataDir, "rooms", dir) if _, err := os.Stat(destDir); os.IsNotExist(err) { failJSON(w, "directory does not exist: "+dir) @@ -1279,8 +1296,11 @@ func (s *AdminServer) handleCreateEmptyRoom(w http.ResponseWriter, r *http.Reque } base := filepath.Join(s.dataDir, "rooms") targetDir := base - if body.Dir != "" { - targetDir = filepath.Join(base, body.Dir) + if dir, err := s.resolveRoomSubdir(body.Dir); err != nil { + failJSON(w, err.Error()) + return + } else if dir != "" { + targetDir = filepath.Join(base, dir) if _, err := os.Stat(targetDir); os.IsNotExist(err) { failJSON(w, "directory not found") return diff --git a/internal/admin/api_rooms_bulk.go b/internal/admin/api_rooms_bulk.go index d10e0e3..b9cbb4a 100644 --- a/internal/admin/api_rooms_bulk.go +++ b/internal/admin/api_rooms_bulk.go @@ -108,7 +108,12 @@ func (s *AdminServer) handleBulkEdit(w http.ResponseWriter, r *http.Request) { newPath := path if patch.Dir != nil && *patch.Dir != "" { - destDir := filepath.Join(s.dataDir, "rooms", *patch.Dir) + dir, dirErr := s.resolveRoomSubdir(*patch.Dir) + if dirErr != nil { + failJSON(w, dirErr.Error()) + return + } + destDir := filepath.Join(s.dataDir, "rooms", dir) if _, statErr := os.Stat(destDir); os.IsNotExist(statErr) { failJSON(w, fmt.Sprintf("directory does not exist: %s", *patch.Dir)) return @@ -130,6 +135,10 @@ func (s *AdminServer) handleBulkEdit(w http.ResponseWriter, r *http.Request) { failJSON(w, fmt.Sprintf("marshal room %d: %v", id, marshalErr)) return } + if err := validateRoomSchema(newContent); err != nil { + failJSON(w, fmt.Sprintf("room %d failed schema validation: %v", id, err)) + return + } writes = append(writes, roomWrite{id: id, oldPath: path, newPath: newPath, oldContent: oldContent, newContent: newContent}) } diff --git a/internal/admin/api_rooms_bulk_delete.go b/internal/admin/api_rooms_bulk_delete.go index 5b525f6..6017eed 100644 --- a/internal/admin/api_rooms_bulk_delete.go +++ b/internal/admin/api_rooms_bulk_delete.go @@ -8,6 +8,7 @@ import ( "net/http" "os" + "thehouseoficarus/internal/persistence" "thehouseoficarus/internal/world" ) @@ -74,13 +75,14 @@ func (s *AdminServer) handleBulkDelete(w http.ResponseWriter, r *http.Request) { deletedSet := make(map[int]bool, len(req.IDs)) for _, snap := range snapshots { - s.detachRoomFromCourse(snap.id) if err := os.Remove(snap.path); err != nil { // All-or-nothing: restore every room deleted so far so a failed - // bulk delete never strands rooms without an undo record. + // bulk delete never strands rooms without an undo record. Course + // detaches are deferred until every file is gone, so a failed + // delete cannot leave course references already rewritten. for _, done := range snapshots { if deletedSet[done.id] { - if werr := os.WriteFile(done.path, done.oldContent, 0644); werr != nil { + if werr := persistence.WriteFile(done.path, done.oldContent, 0644); werr != nil { log.Printf("bulk-delete rollback of room %d: %v", done.id, werr) } } @@ -90,6 +92,10 @@ func (s *AdminServer) handleBulkDelete(w http.ResponseWriter, r *http.Request) { } deletedSet[snap.id] = true } + // Room files are gone; now detach the deleted rooms from any courses. + for _, snap := range snapshots { + s.detachRoomFromCourse(snap.id) + } s.world.RebuildRoomIndex(s.dataDir) for _, snap := range snapshots { diff --git a/internal/admin/fixes_regression_test.go b/internal/admin/fixes_regression_test.go new file mode 100644 index 0000000..3d7d19f --- /dev/null +++ b/internal/admin/fixes_regression_test.go @@ -0,0 +1,49 @@ +package admin + +import ( + "path/filepath" + "testing" +) + +// TestResolveRoomSubdirRejectsEscape covers the traversal guard used by the +// room-directory API handlers (rename dir, move room, create empty room, bulk +// edit). Every escaping form must be rejected. +func TestResolveRoomSubdirRejectsEscape(t *testing.T) { + s := &AdminServer{dataDir: t.TempDir()} + for _, bad := range []string{"..", "../items", "../../etc", "/etc", "a/../../b", `..\items`, "town/../../x"} { + if _, err := s.resolveRoomSubdir(bad); err == nil { + t.Fatalf("accepted escaping dir %q", bad) + } + } + for _, ok := range []string{"", ".", "town", "town/sub"} { + got, err := s.resolveRoomSubdir(ok) + if err != nil { + t.Fatalf("rejected valid dir %q: %v", ok, err) + } + if ok != "" && ok != "." && got != filepath.FromSlash(ok) { + t.Fatalf("dir %q resolved to %q", ok, got) + } + } +} + +// TestUndoHistorySurvivesReload: the editor documents that undo history is +// persisted across sessions; NewUndoStack must load what save wrote. +func TestUndoHistorySurvivesReload(t *testing.T) { + dir := t.TempDir() + us := NewUndoStack(dir) + us.Push(ChangeDesc{ + Description: "edit x", + FilePath: filepath.Join(dir, "x.yaml"), + OldContent: []byte("old"), + NewContent: []byte("new"), + }) + + reloaded := NewUndoStack(dir) + info := reloaded.Info() + if !info.CanUndo || info.StackSize != 1 { + t.Fatalf("history did not survive reload: %+v", info) + } + if info.UndoDesc == "" { + t.Fatal("undo description empty after reload") + } +} diff --git a/internal/admin/server.go b/internal/admin/server.go index 12d090d..d9a0386 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -324,19 +324,27 @@ func (s *AdminServer) checkAuth(r *http.Request) bool { if err != nil { return false } - parts := strings.SplitN(cookie.Value, ":", 2) - if len(parts) != 2 { + parts := strings.SplitN(cookie.Value, ":", 3) + if len(parts) != 3 { return false } account := parts[0] - sig := parts[1] + issued, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil { + return false + } + issuedAt := time.Unix(issued, 0) + if time.Since(issuedAt) > adminSessionTTL || time.Until(issuedAt) > time.Minute { + return false + } + sig := parts[2] if !s.isAdminAccount(account) { return false } mac := hmac.New(sha256.New, s.cookieSecret) - mac.Write([]byte(account)) + mac.Write([]byte(account + ":" + parts[1])) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(sig), []byte(expected)) } @@ -349,18 +357,24 @@ func (s *AdminServer) isAdminAccount(name string) bool { return acc.Admin } +// adminSessionTTL bounds how long a signed admin cookie is accepted. The +// signature covers the issue time, so a leaked cookie cannot authenticate +// indefinitely and the server can age it out without server-side state. +const adminSessionTTL = 24 * time.Hour + func (s *AdminServer) setAuthCookie(w http.ResponseWriter, account string) { + issued := strconv.FormatInt(time.Now().Unix(), 10) mac := hmac.New(sha256.New, s.cookieSecret) - mac.Write([]byte(account)) + mac.Write([]byte(account + ":" + issued)) sig := hex.EncodeToString(mac.Sum(nil)) http.SetCookie(w, &http.Cookie{ Name: "admin_session", - Value: account + ":" + sig, + Value: account + ":" + issued + ":" + sig, Path: "/", HttpOnly: true, Secure: s.useTLS, SameSite: http.SameSiteStrictMode, - MaxAge: 86400, + MaxAge: int(adminSessionTTL / time.Second), }) } @@ -409,7 +423,6 @@ func (s *AdminServer) handleLogin(w http.ResponseWriter, r *http.Request) { log.Printf("admin login: password rehash save for %s: %v", account, err) } } - s.undoStack.Clear() }) { writeJSONError(w, "game is shutting down", http.StatusServiceUnavailable) return diff --git a/internal/admin/undo.go b/internal/admin/undo.go index 51610f8..1786295 100644 --- a/internal/admin/undo.go +++ b/internal/admin/undo.go @@ -17,6 +17,7 @@ type ExtraFile struct { NewFilePath string `json:"new_file_path,omitempty"` IsDelete bool `json:"is_delete,omitempty"` IsCreate bool `json:"is_create,omitempty"` + IsDirDelete bool `json:"is_dir_delete,omitempty"` } type ChangeDesc struct { @@ -28,6 +29,7 @@ type ChangeDesc struct { NewContent []byte `json:"new_content"` IsDelete bool `json:"is_delete"` IsCreate bool `json:"is_create"` + IsDirDelete bool `json:"is_dir_delete,omitempty"` ExtraFiles []ExtraFile `json:"extra_files,omitempty"` // IDMap records a room-ID change (oldID -> newID) included in the // change. Undo applies its inverse, redo the forward map, through the @@ -78,11 +80,13 @@ type UndoStack struct { } func NewUndoStack(dataDir string) *UndoStack { - return &UndoStack{ + us := &UndoStack{ dataDir: dataDir, maxSize: 100, filePath: filepath.Join(dataDir, ".admin_history.json"), } + us.load() + return us } // SetMigrateRoomIDs wires the runtime room-ID migration used by undo/redo @@ -174,11 +178,14 @@ func (us *UndoStack) applyUndo(c ChangeDesc) error { } } if c.IsDelete { - if c.OldContent == nil { + if c.IsDirDelete { if err := os.MkdirAll(c.FilePath, 0755); err != nil { return fmt.Errorf("recreate dir %s: %w", c.FilePath, err) } } else { + if c.OldContent == nil { + return fmt.Errorf("restore deleted %s: no snapshot recorded", c.FilePath) + } if err := os.MkdirAll(filepath.Dir(c.FilePath), 0755); err != nil { return fmt.Errorf("mkdir: %w", err) } @@ -266,7 +273,14 @@ func applyUndoExtraFile(ef ExtraFile) error { if err := os.Remove(ef.FilePath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("undo remove %s: %w", ef.FilePath, err) } + case ef.IsDelete && ef.IsDirDelete: + if err := os.MkdirAll(ef.FilePath, 0755); err != nil { + return fmt.Errorf("undo mkdir: %w", err) + } case ef.IsDelete: + if ef.OldContent == nil { + return fmt.Errorf("undo restore %s: no snapshot recorded", ef.FilePath) + } if err := os.MkdirAll(filepath.Dir(ef.FilePath), 0755); err != nil { return fmt.Errorf("undo mkdir: %w", err) } @@ -354,6 +368,7 @@ func (us *UndoStack) save() { NewContent string `json:"new_content"` IsDelete bool `json:"is_delete,omitempty"` IsCreate bool `json:"is_create,omitempty"` + IsDirDelete bool `json:"is_dir_delete,omitempty"` } type entry struct { Time string `json:"time"` @@ -364,6 +379,7 @@ func (us *UndoStack) save() { NewContent string `json:"new_content"` IsDelete bool `json:"is_delete"` IsCreate bool `json:"is_create"` + IsDirDelete bool `json:"is_dir_delete,omitempty"` ExtraFiles []extraEntry `json:"extra_files,omitempty"` IDMap map[int]int `json:"id_map,omitempty"` } @@ -383,6 +399,7 @@ func (us *UndoStack) save() { NewContent: string(ef.NewContent), IsDelete: ef.IsDelete, IsCreate: ef.IsCreate, + IsDirDelete: ef.IsDirDelete, }) } entries = append(entries, entry{ @@ -394,6 +411,7 @@ func (us *UndoStack) save() { NewContent: string(c.NewContent), IsDelete: c.IsDelete, IsCreate: c.IsCreate, + IsDirDelete: c.IsDirDelete, ExtraFiles: extra, IDMap: c.IDMap, }) @@ -414,6 +432,79 @@ func (us *UndoStack) save() { } } +// load restores persisted undo/redo history written by save. A missing or +// unreadable history file is not fatal: the stack simply starts empty. The +// editor documents that history survives restarts, so NewUndoStack calls this. +func (us *UndoStack) load() { + data, err := os.ReadFile(us.filePath) + if err != nil { + return + } + type extraEntry struct { + FilePath string `json:"file_path"` + NewFilePath string `json:"new_file_path,omitempty"` + OldContent string `json:"old_content"` + NewContent string `json:"new_content"` + IsDelete bool `json:"is_delete,omitempty"` + IsCreate bool `json:"is_create,omitempty"` + IsDirDelete bool `json:"is_dir_delete,omitempty"` + } + type entry struct { + Time string `json:"time"` + Description string `json:"description"` + FilePath string `json:"file_path"` + NewFilePath string `json:"new_file_path,omitempty"` + OldContent string `json:"old_content"` + NewContent string `json:"new_content"` + IsDelete bool `json:"is_delete"` + IsCreate bool `json:"is_create"` + IsDirDelete bool `json:"is_dir_delete,omitempty"` + ExtraFiles []extraEntry `json:"extra_files,omitempty"` + IDMap map[int]int `json:"id_map,omitempty"` + } + type saveData struct { + History []entry `json:"history"` + Redo []entry `json:"redo"` + } + var sd saveData + if err := json.Unmarshal(data, &sd); err != nil { + log.Printf("undo: failed to load history: %v", err) + return + } + toChanges := func(entries []entry) []ChangeDesc { + out := make([]ChangeDesc, 0, len(entries)) + for _, e := range entries { + c := ChangeDesc{ + Time: e.Time, + Description: e.Description, + FilePath: e.FilePath, + NewFilePath: e.NewFilePath, + OldContent: []byte(e.OldContent), + NewContent: []byte(e.NewContent), + IsDelete: e.IsDelete, + IsCreate: e.IsCreate, + IsDirDelete: e.IsDirDelete, + IDMap: e.IDMap, + } + for _, ef := range e.ExtraFiles { + c.ExtraFiles = append(c.ExtraFiles, ExtraFile{ + FilePath: ef.FilePath, + NewFilePath: ef.NewFilePath, + OldContent: []byte(ef.OldContent), + NewContent: []byte(ef.NewContent), + IsDelete: ef.IsDelete, + IsCreate: ef.IsCreate, + IsDirDelete: ef.IsDirDelete, + }) + } + out = append(out, c) + } + return out + } + us.history = toChanges(sd.History) + us.redo = toChanges(sd.Redo) +} + func (us *UndoStack) Clear() { us.mu.Lock() defer us.mu.Unlock() diff --git a/internal/admin/yaml_util.go b/internal/admin/yaml_util.go index c042248..44712a3 100644 --- a/internal/admin/yaml_util.go +++ b/internal/admin/yaml_util.go @@ -9,6 +9,8 @@ import ( "strings" "gopkg.in/yaml.v3" + + "thehouseoficarus/internal/persistence" ) func readYAMLFile(dataDir, subdir, id string) ([]byte, string, error) { @@ -35,7 +37,7 @@ func writeYAMLFile(path string, data any) ([]byte, error) { if err != nil { return nil, err } - if err := os.WriteFile(path, content, 0644); err != nil { + if err := persistence.WriteFile(path, content, 0644); err != nil { return nil, err } return content, nil @@ -338,6 +340,7 @@ func deleteEntityDir(dataDir, subdir, dir string) ([]ChangeDesc, error) { Description: fmt.Sprintf("delete directory %s/%s", subdir, dir), FilePath: srcDir, IsDelete: true, + IsDirDelete: true, }) return changes, nil |
