diff --git a/backupfs.go b/backupfs.go index 7ea6a8a..b7d0c3c 100644 --- a/backupfs.go +++ b/backupfs.go @@ -7,12 +7,9 @@ import ( "io/fs" "log" "os" - "path" "path/filepath" - "runtime" "sort" "sync" - "syscall" "time" ) @@ -45,7 +42,7 @@ func NewWithFS(baseFS FS, backupLocation string, opts ...BackupFSOption) *Backup NewHiddenFS(baseFS, backupLocation), NewPrefixFS(baseFS, backupLocation), // put our default option first in order for it to be overwritable later - append([]BackupFSOption{WithVolumePaths(true)}, opts...)..., + append([]BackupFSOption{ /* default options that can be overwritten afterwards */ }, opts...)..., ) return fsys } @@ -60,9 +57,8 @@ func NewBackupFS(base, backup FS, opts ...BackupFSOption) *BackupFS { } bfsys := &BackupFS{ - windowsVolumePaths: opt.allowWindowsVolumePaths, - base: base, - backup: backup, + base: base, + backup: backup, // this map is needed in order to keep track of non existing files // consecutive changes might lead to files being backed up @@ -90,11 +86,8 @@ type BackupFS struct { // file system. // it is not nil in case that the file existed on the base file system baseInfos map[string]fs.FileInfo - mu sync.Mutex - // windowsVolumePaths can be set to true in order to allow fully - // qualified windows paths (including volume names C:\A\B\C instead of \A\B\C) - windowsVolumePaths bool + mu sync.Mutex } // BaseFS returns the fs layer that is being written to @@ -112,229 +105,7 @@ func (fsys *BackupFS) Name() string { return "BackupFS" } -// Rollback tries to rollback the backup back to the -// base system removing any new files for the base -// system and restoring any old files from the backup -// Best effort, any errors due to filesystem -// modification on the backup site are skipped -// This is a heavy weight operation which blocks the file system -// until the rollback is done. -func (fsys *BackupFS) Rollback() (multiErr error) { - defer func() { - if multiErr != nil { - multiErr = errors.Join(ErrRollbackFailed, multiErr) - } - }() - fsys.mu.Lock() - defer fsys.mu.Unlock() - - var ( - // these file sneed to be removed in a certain order, so we keep track of them - // from most nested to least nested files - // can be any file type, dir, file, symlink - removeBasePaths = make([]string, 0, 1) - - // these files also need to be restored in a certain order - // from least nested to most nested - restoreDirPaths = make([]string, 0, 4) - restoreFilePaths = make([]string, 0, 4) - restoreSymlinkPaths = make([]string, 0, 4) - - err error - exists bool - ) - - for path, info := range fsys.baseInfos { - if info == nil { - // file did not exist in the base filesystem at the point of - // filesystem modification. - exists, err = lExists(fsys.base, path) - if err != nil { - multiErr = errors.Join(multiErr, fmt.Errorf("failed to check whether file %s exists in base filesystem: %w", path, err)) - continue - } - - if exists { - // we will need to delete this file - removeBasePaths = append(removeBasePaths, path) - } - - // case where file must be removed in base file system - // finished - continue - } - - mode := info.Mode() - switch { - case mode.IsDir(): - restoreDirPaths = append(restoreDirPaths, path) - case mode.IsRegular(): - restoreFilePaths = append(restoreFilePaths, path) - case mode&os.ModeSymlink != 0: - restoreSymlinkPaths = append(restoreSymlinkPaths, path) - default: - log.Printf("unknown file type: %s\n", path) - } - } - - err = fsys.tryRemoveBasePaths(removeBasePaths) - if err != nil { - multiErr = errors.Join(err) - } - - err = fsys.tryRestoreDirPaths(restoreDirPaths) - if err != nil { - multiErr = errors.Join(multiErr, err) - } - - err = fsys.tryRestoreFilePaths(restoreFilePaths) - if err != nil { - multiErr = errors.Join(multiErr, err) - } - - err = fsys.tryRestoreSymlinkPaths(restoreSymlinkPaths) - if err != nil { - multiErr = errors.Join(multiErr, err) - } - - // TODO: make this optional?: whether to delete the backup upon rollback - - // at this point we were able to restore all of the files - // now we need to delete our backup - err = fsys.tryRemoveBackupPaths("symlink", restoreSymlinkPaths) - if err != nil { - multiErr = errors.Join(multiErr, err) - } - - // delete files before directories in order for directories to be empty - err = fsys.tryRemoveBackupPaths("file", restoreFilePaths) - if err != nil { - multiErr = errors.Join(multiErr, err) - } - - // best effort deletion of backup files - // so we ignore the error - // we only delete directories that we did create. - // any user created content in directories is not touched - - err = fsys.tryRemoveBackupPaths("directory", restoreDirPaths) - if err != nil { - multiErr = errors.Join(multiErr, err) - } - - // in case of a multiError we are not able to restore the previous state anyway - // that is why we continue here to finish the rollback but at the same time inform - // the user about potential errors along the way. - - // at this point we have successfully restored our backup and - // removed all of the backup files and directories - - // now we can reset the internal data structure for book keeping of filesystem modifications - fsys.baseInfos = make(map[string]fs.FileInfo) - return multiErr -} - -func (fsys *BackupFS) tryRemoveBasePaths(removeBasePaths []string) (multiErr error) { - var err error - // remove files from most nested to least nested - sort.Sort(ByMostFilePathSeparators(removeBasePaths)) - for _, remPath := range removeBasePaths { - // remove all files that were not there before the backup. - // ignore error, as this is a best effort restoration. - // folders and files did not exist in the first place - err = fsys.base.Remove(remPath) - if err != nil { - multiErr = errors.Join(multiErr, fmt.Errorf("failed to remove path in base filesystem %s: %w", remPath, err)) - } - } - return multiErr -} - -func (fsys *BackupFS) tryRemoveBackupPaths(fileType string, removeBackupPaths []string) (multiErr error) { - var ( - err error - found bool - ) - - // remove files from most nested to least nested - sort.Sort(ByMostFilePathSeparators(removeBackupPaths)) - for _, remPath := range removeBackupPaths { - found, err = lExists(fsys.backup, remPath) - if err != nil { - multiErr = errors.Join(multiErr, fmt.Errorf("failed to check whether %s exists in backup filesystem %s: %w", fileType, remPath, err)) - continue - } - - if !found { - // nothing to remove - continue - } - - // remove all files that were not there before the backup. - // WARNING: do not change this to RemoveAll, as we do not want to renove user created content - // in directories - err = fsys.backup.Remove(remPath) - if err != nil { - multiErr = errors.Join(multiErr, fmt.Errorf("failed to remove %s in backup filesystem %s: %w", fileType, remPath, err)) - } - } - return multiErr -} - -func (fsys *BackupFS) tryRestoreDirPaths(restoreDirPaths []string) (multiErr error) { - // in order to iterate over parent directories before child directories - sort.Sort(ByLeastFilePathSeparators(restoreDirPaths)) - var err error - for _, dirPath := range restoreDirPaths { - // backup -> base filesystem - err = copyDir(fsys.base, dirPath, fsys.baseInfos[dirPath]) - if err != nil { - multiErr = errors.Join(multiErr, err) - } - } - return multiErr -} - -func (fsys *BackupFS) tryRestoreSymlinkPaths(restoreSymlinkPaths []string) (multiErr error) { - // in this case it does not matter whether we sort the symlink paths or not - // we prefer to sort them in order to see potential errors better - sort.Strings(restoreSymlinkPaths) - var err error - for _, symlinkPath := range restoreSymlinkPaths { - err = restoreSymlink( - symlinkPath, - fsys.baseInfos[symlinkPath], - fsys.base, - fsys.backup, - ) - if err != nil { - // in this case it might make sense to retry the rollback - multiErr = errors.Join(multiErr, err) - } - } - - return multiErr -} - -func (fsys *BackupFS) tryRestoreFilePaths(restoreFilePaths []string) (multiErr error) { - // in this case it does not matter whether we sort the file paths or not - // we prefer to sort them in order to see potential errors better - sort.Strings(restoreFilePaths) - var err error - for _, filePath := range restoreFilePaths { - err = restoreFile(filePath, fsys.baseInfos[filePath], fsys.base, fsys.backup) - if err != nil { - // in this case it might make sense to retry the rollback - multiErr = errors.Join(multiErr, err) - } - } - - return multiErr -} - func (fsys *BackupFS) Map() (metadata map[string]fs.FileInfo) { - fsys.mu.Lock() - defer fsys.mu.Unlock() m := make(map[string]fs.FileInfo, len(fsys.baseInfos)) for path, info := range fsys.baseInfos { @@ -360,58 +131,15 @@ func (fsys *BackupFS) SetMap(metadata map[string]fs.FileInfo) { m[path] = info } - fsys.mu.Lock() - defer fsys.mu.Unlock() - fsys.baseInfos = m } -func toFInfo(path string, fi fs.FileInfo) *fInfo { - return &fInfo{ - FileName: filepath.ToSlash(path), - FileMode: uint32(fi.Mode()), - FileModTime: fi.ModTime().UnixNano(), - FileSize: fi.Size(), - FileUid: toUID(fi), - FileGid: toGID(fi), - } -} - -type fInfo struct { - FileName string `json:"name"` - FileMode uint32 `json:"mode"` - FileModTime int64 `json:"mod_time"` - FileSize int64 `json:"size"` - FileUid int `json:"uid"` - FileGid int `json:"gid"` -} - -func (fi *fInfo) Name() string { - return path.Base(fi.FileName) -} -func (fi *fInfo) Size() int64 { - return fi.FileSize -} -func (fi *fInfo) Mode() fs.FileMode { - return fs.FileMode(fi.FileMode) -} -func (fi *fInfo) ModTime() time.Time { - return time.Unix(fi.FileModTime/1000000000, fi.FileModTime%1000000000) -} -func (fi *fInfo) IsDir() bool { - return fi.Mode().IsDir() -} -func (fi *fInfo) Sys() interface{} { - return nil -} - func (fsys *BackupFS) MarshalJSON() ([]byte, error) { - fsys.mu.Lock() - defer fsys.mu.Unlock() + m := fsys.Map() - fiMap := make(map[string]*fInfo, len(fsys.baseInfos)) + fiMap := make(map[string]*fInfo, len(m)) - for path, fi := range fsys.baseInfos { + for path, fi := range m { if fi == nil { fiMap[path] = nil continue @@ -424,8 +152,6 @@ func (fsys *BackupFS) MarshalJSON() ([]byte, error) { } func (fsys *BackupFS) UnmarshalJSON(data []byte) error { - fsys.mu.Lock() - defer fsys.mu.Unlock() fiMap := make(map[string]*fInfo) @@ -448,721 +174,909 @@ func (fsys *BackupFS) UnmarshalJSON(data []byte) error { return nil } -// returns the cleaned path -func (fsys *BackupFS) realPath(name string) (path string, err error) { - // check path for being an absolute windows path - if !fsys.windowsVolumePaths && runtime.GOOS == "windows" && filepath.IsAbs(name) { - // On Windows a common mistake would be to provide an absolute OS path - // We could strip out the base part, but that would not be very portable. +func (fsys *BackupFS) ForceBackup(name string) (err error) { + defer func() { + if err != nil { + err = &os.PathError{Op: "force_backup", Err: err, Path: name} + } + }() - return name, fs.ErrNotExist + fsys.mu.Lock() + defer fsys.mu.Unlock() + + resolvedName, err := fsys.realPath(name) + if err != nil { + return err + } + + err = fsys.tryRemoveBackup(resolvedName) + if err != nil { + return err + } + err = fsys.tryBackup(resolvedName) + if err != nil { + return err } - return filepath.Clean(name), nil + return nil } -// keeps track of files in the base filesystem. -// Files are saved only once, any consecutive update is ignored. -func (fsys *BackupFS) setBaseInfoIfNotFound(path string, info fs.FileInfo) { - _, found := fsys.baseInfos[path] - if !found { - fsys.baseInfos[path] = info +// Create creates a file in the filesystem, returning the file and an +// error, if any happens. +func (fsys *BackupFS) Create(name string) (_ File, err error) { + defer func() { + if err != nil { + err = &os.PathError{Op: "create", Path: name, Err: err} + } + }() + fsys.mu.Lock() + defer fsys.mu.Unlock() + + resolvedName, err := fsys.realPath(name) + if err != nil { + return nil, err } -} -// alreadyFoundBaseInfo returns true when we already visited this path. -// This is a helper function in order to NOT call the Stat method of the baseFS -// an unnecessary amount of times for filepath sub directories when we can just lookup -// the information in out internal filepath map -func (fsys *BackupFS) alreadyFoundBaseInfo(path string) bool { - _, found := fsys.baseInfos[path] - return found + err = fsys.tryBackup(resolvedName) + if err != nil { + return nil, err + } + + // create or truncate file + file, err := fsys.base.Create(resolvedName) + if err != nil { + return nil, err + } + return file, nil } -// Stat returns a FileInfo describing the named file, or an error, if any happens. -// Stat only looks at the base filesystem and returns the stat of the files at the specified path -func (fsys *BackupFS) Stat(name string) (fs.FileInfo, error) { +// Mkdir creates a directory in the filesystem, return an error if any +// happens. +func (fsys *BackupFS) Mkdir(name string, perm fs.FileMode) (err error) { + defer func() { + if err != nil { + err = &os.PathError{Op: "mkdir", Path: name, Err: err} + } + }() fsys.mu.Lock() defer fsys.mu.Unlock() - return fsys.stat(name) -} + resolvedName, err := fsys.realPath(name) + if err != nil { + return err + } -func (fsys *BackupFS) stat(name string) (fs.FileInfo, error) { - name, err := fsys.realPath(name) + err = fsys.tryBackup(resolvedName) if err != nil { - return nil, &os.PathError{Op: "stat", Path: name, Err: fmt.Errorf("failed to clean path: %w", err)} + return err } - // we want to check all parent directories before we check the actual file. - // in order to keep track of their state as well. - // /root -> /root/sub/ -> /root/sub/sub1 - // iterate parent directories and keep track of their initial state. - _, _ = IterateDirTree(filepath.Dir(name), func(subdirPath string) (bool, error) { - if fsys.alreadyFoundBaseInfo(subdirPath) { - return true, nil - } + err = fsys.base.Mkdir(resolvedName, perm) + if err != nil { + return err + } + return nil +} - // only in case that we have not yet visited one of the subdirs already, - // only then fetch the file information from the underlying baseFS - // we do want to ignore errors as this is only for keeping track of subdirectories - // TODO: in some weird scenario it might be possible for this value to be a symlink - // instead of a directory - _, err := fsys.trackedLstat(subdirPath) +// MkdirAll creates a directory path and all +// parents that does not exist yet. +func (fsys *BackupFS) MkdirAll(name string, perm fs.FileMode) (err error) { + defer func() { if err != nil { - // in case of an error we want to fail fast - return false, nil + err = &os.PathError{Op: "mkdir_all", Path: name, Err: err} } - return true, nil - }) - - return fsys.trackedStat(name) -} + }() -// trackedStat is the tracked variant of Stat that is called on the underlying base FS -func (fsys *BackupFS) trackedStat(name string) (fs.FileInfo, error) { - fi, err := fsys.base.Stat(name) + fsys.mu.Lock() + defer fsys.mu.Unlock() - // keep track of initial + resolvedName, err := fsys.realPath(name) if err != nil { - if oerr, ok := err.(*os.PathError); ok { - if oerr.Err == fs.ErrNotExist || oerr.Err == syscall.ENOENT || oerr.Err == syscall.ENOTDIR { - - fsys.setBaseInfoIfNotFound(name, nil) - return nil, err - } - } - if err == syscall.ENOENT { - fsys.setBaseInfoIfNotFound(name, nil) - return nil, err - } + return err } + err = fsys.tryBackup(resolvedName) if err != nil { - return nil, err + return err } - fsys.setBaseInfoIfNotFound(name, fi) - return fi, nil + err = fsys.base.MkdirAll(resolvedName, perm) + if err != nil { + return err + } + return nil } -// backupRequired checks whether a file that is about to be changed needs to be backed up. -// files that do not exist in the BackupFS need to be backed up. -// files that do exist in the BackupFS either as files or in the baseInfos map as non-existing files -// do not need to be backed up (again) -func (fsys *BackupFS) backupRequired(path string) (info fs.FileInfo, required bool, err error) { - fsys.mu.Lock() - defer fsys.mu.Unlock() +// OpenFile opens a file using the given flags and the given mode. +func (fsys *BackupFS) OpenFile(name string, flag int, perm fs.FileMode) (_ File, err error) { + defer func() { + if err != nil { + err = &os.PathError{Op: "open", Path: name, Err: err} + } + }() - info, found := fsys.baseInfos[path] - if !found { - // fill fsys.baseInfos - // of symlink, file & directory as well as their parent directories. - info, err = fsys.lstat(path) - if err != nil && errors.Is(err, fs.ErrNotExist) { - // not found, no backup needed - return nil, false, nil - } else if err != nil { - return nil, false, err + // read only operations do not require backups nor path resolution + if flag == os.O_RDONLY { + // in read only mode the perm is not used. + f, err := fsys.base.OpenFile(name, os.O_RDONLY, 0) + if err != nil { + return nil, err } - // err == nil + return f, nil } - // at this point info is either set by baseInfos or by fs.tat - if info == nil { - //actually no file expected at that location - return nil, false, nil - } + fsys.mu.Lock() + defer fsys.mu.Unlock() - // file found at base fs location + // write operations require path resolution due to + // potentially required backups + resolvedName, err := fsys.realPath(name) + if err != nil { + return nil, err + } - // did we already backup that file? - foundBackup, err := lExists(fsys.backup, path) + // not read only opening -> backup + err = fsys.tryBackup(resolvedName) if err != nil { - return nil, false, err + return nil, err } - if foundBackup { - // no need to backup, as we already backed up the file - return nil, false, nil + file, err := fsys.base.OpenFile(resolvedName, flag, perm) + if err != nil { + return nil, err } + return file, nil +} - // backup is needed - return info, true, nil +// Remove removes a file identified by name, returning an error, if any +// happens. +func (fsys *BackupFS) Remove(name string) (err error) { + fsys.mu.Lock() + defer fsys.mu.Unlock() + return fsys.remove(name) } -func (fsys *BackupFS) ForceBackup(name string) (err error) { - name, err = fsys.realPath(name) +func (fsys *BackupFS) remove(name string) (err error) { + defer func() { + if err != nil { + err = &os.PathError{Op: "remove", Path: name, Err: err} + } + }() + + resolvedName, err := fsys.realPath(name) if err != nil { - return &os.PathError{Op: "force_backup", Err: fmt.Errorf("failed to clean path: %w", err), Path: name} + return err } - err = fsys.tryRemoveBackup(name) + err = fsys.tryBackup(resolvedName) if err != nil { - return &os.PathError{Op: "force_backup", Err: fmt.Errorf("failed to remove backup: %w", err), Path: name} + return err } - err = fsys.tryBackup(name) + err = fsys.base.Remove(resolvedName) if err != nil { - return &os.PathError{Op: "force_backup", Err: fmt.Errorf("backup failed: %w", err), Path: name} + return err } return nil } -func (fsys *BackupFS) tryRemoveBackup(name string) (err error) { - _, needsBackup, err := fsys.backupRequired(name) +// RemoveAll removes a directory path and any children it contains. It +// does not fail if the path does not exist (return nil). +// not supported +func (fsys *BackupFS) RemoveAll(name string) (err error) { + defer func() { + if err != nil { + err = &os.PathError{Op: "remove_all", Path: name, Err: err} + } + }() + fsys.mu.Lock() + defer fsys.mu.Unlock() + + resolvedName, err := fsys.realPath(name) if err != nil { return err } - // there is no backup - if needsBackup { - return nil - } - fi, err := fsys.backup.Lstat(name) - if err != nil && !errors.Is(err, fs.ErrNotExist) { + // does not exist or no access, nothing to do + fi, err := fsys.Lstat(resolvedName) + if err != nil { return err } - // file not found - if fi == nil { - // nothing to remove, except internal state if it exists - fsys.mu.Lock() - defer fsys.mu.Unlock() - - delete(fsys.baseInfos, name) - return nil - } - if !fi.IsDir() { - defer func() { - if err == nil { - // only delete from internal state - // when file has been deleted - // this allows to retry the deletion attempt - fsys.mu.Lock() - delete(fsys.baseInfos, name) - fsys.mu.Unlock() - } - }() - // remove file/symlink - err := fsys.backup.Remove(name) + // if it's a file or a symlink, directly remove it + err = fsys.remove(resolvedName) if err != nil { return err } return nil } - dirs := make([]string, 0) - - err = Walk(fsys.backup, name, func(path string, info fs.FileInfo, err error) (e error) { - // and then check for error + resolvedDirPaths := make([]string, 0, 1) + err = Walk(fsys.base, resolvedName, func(resolvedSubPath string, info fs.FileInfo, err error) error { if err != nil { return err } - defer func() { - if e == nil { - // delete dirs and files from internal map - // but only after re have removed the file successfully - fsys.mu.Lock() - delete(fsys.baseInfos, path) - fsys.mu.Unlock() - } - }() - if info.IsDir() { - // keep track of dirs - dirs = append(dirs, path) + // initially we want to delete all files before we delete all of the directories + // but we also want to keep track of all found directories in order not to walk the + // dir tree again. + resolvedDirPaths = append(resolvedDirPaths, resolvedSubPath) return nil } - // delete files - err = fsys.backup.Remove(path) + return fsys.remove(resolvedSubPath) + }) + if err != nil { + return err + } + + // after deleting all of the files + //now we want to sort all of the file paths from the most + //nested file to the least nested file (count file path separators) + sort.Sort(ByMostFilePathSeparators(resolvedDirPaths)) + + for _, emptyDir := range resolvedDirPaths { + err = fsys.remove(emptyDir) if err != nil { return err } - return nil - }) + } + + return nil +} + +// Rename renames a file. +func (fsys *BackupFS) Rename(oldname, newname string) (err error) { + defer func() { + if err != nil { + err = &os.LinkError{Op: "rename", Old: oldname, New: newname, Err: err} + } + }() + fsys.mu.Lock() + defer fsys.mu.Unlock() + + resolvedOldname, err := fsys.realPath(oldname) if err != nil { return err } - sort.Sort(ByMostFilePathSeparators(dirs)) + resolvedNewname, newNameFound, err := fsys.realPathWithFound(newname) + if err != nil { + return err + } - for _, dir := range dirs { - err = fsys.backup.RemoveAll(dir) + if !newNameFound { + // only make file known in case that it does not exist, otherwise + // overwriting would return an error anyway. + err = fsys.tryBackup(resolvedNewname) if err != nil { return err } - // delete directory from internal - // state only after it has been actually deleted - fsys.mu.Lock() - delete(fsys.baseInfos, dir) - fsys.mu.Unlock() + // there either was no previous file to be backed up + // but now we know that there was no file or there + // was a target file that has to be backed up which was then backed up + err = fsys.tryBackup(resolvedOldname) + if err != nil { + return err + } } + // in the else case Renaming to a file that already exists + // the Rename call will return an error anyway, so we do not backup anything in that case. + err = fsys.base.Rename(resolvedOldname, resolvedNewname) + if err != nil { + return err + } return nil } -func (fsys *BackupFS) tryBackup(name string) (err error) { +// Chmod changes the mode of the named file to mode. +func (fsys *BackupFS) Chmod(name string, mode fs.FileMode) (err error) { + defer func() { + if err != nil { + err = &os.PathError{Op: "chmod", Path: name, Err: err} + } + }() + fsys.mu.Lock() + defer fsys.mu.Unlock() - info, needsBackup, err := fsys.backupRequired(name) + resolvedName, err := fsys.realPath(name) if err != nil { return err } - if !needsBackup { - return nil + + err = fsys.tryBackup(resolvedName) + if err != nil { + return err } - dirPath := name - if !info.IsDir() { - // is file, get dir - dirPath = filepath.Dir(dirPath) + err = fsys.base.Chmod(resolvedName, mode) + if err != nil { + return err } + return nil +} - _, err = IterateDirTree(dirPath, func(subDirPath string) (bool, error) { - fi, required, err := fsys.backupRequired(subDirPath) +// Chown changes the uid and gid of the named file. +func (fsys *BackupFS) Chown(name string, uid, gid int) (err error) { + defer func() { if err != nil { - return false, err + err = &os.PathError{Op: "chown", Path: name, Err: err} } + }() + fsys.mu.Lock() + defer fsys.mu.Unlock() - if !required { - return true, nil + resolvedName, err := fsys.realPath(name) + if err != nil { + return err + } + + err = fsys.tryBackup(resolvedName) + if err != nil { + return err + } + + err = fsys.base.Chown(resolvedName, uid, gid) + if err != nil { + return err + } + return nil +} + +// Chtimes changes the access and modification times of the named file +func (fsys *BackupFS) Chtimes(name string, atime, mtime time.Time) (err error) { + defer func() { + if err != nil { + err = &os.PathError{Op: "chown", Path: name, Err: err} } + }() + fsys.mu.Lock() + defer fsys.mu.Unlock() + + resolvedName, err := fsys.realPath(name) + if err != nil { + return err + } + + err = fsys.tryBackup(resolvedName) + if err != nil { + return err + } + err = fsys.base.Chtimes(resolvedName, atime, mtime) + if err != nil { + return err + } + + return nil +} - err = copyDir(fsys.backup, subDirPath, fi) +// Symlink changes the access and modification times of the named file +func (fsys *BackupFS) Symlink(oldname, newname string) (err error) { + defer func() { if err != nil { - return false, err + err = &os.LinkError{Op: "symlink", Old: oldname, New: newname, Err: err} } - return true, nil - }) + }() + fsys.mu.Lock() + defer fsys.mu.Unlock() + + // cannot resolve oldname because it is not touched and it may also contain relative paths + resolvedNewname, err := fsys.realPath(newname) if err != nil { return err } - fileMode := info.Mode() - switch { - case fileMode.IsDir(): - // file was actually a directory, - // we did already backup all of the directory tree - return nil + // we only want to backup the newname, + // as seemingly the new name is the target symlink location + // the old file path should not have been modified - case fileMode.IsRegular(): - // name was a path to a file - // create the file - sf, err := fsys.base.Open(name) + // in case we fail to backup the symlink, we return an error + err = fsys.tryBackup(resolvedNewname) + if err != nil { + return err + } + + err = fsys.base.Symlink(oldname, resolvedNewname) + if err != nil { + return err + } + return nil +} + +// Lchown does not fallback to chown. It does return an error in case that lchown cannot be called. +func (fsys *BackupFS) Lchown(name string, uid, gid int) (err error) { + defer func() { if err != nil { - return err + err = &os.PathError{Op: "lchown", Path: name, Err: err} } - defer sf.Close() - return copyFile(fsys.backup, name, info, sf) + }() + fsys.mu.Lock() + defer fsys.mu.Unlock() - case fileMode&os.ModeSymlink != 0: - // symlink - return copySymlink( - fsys.base, - fsys.backup, - name, - info, - ) + resolvedName, err := fsys.realPath(name) + if err != nil { + return err + } - default: - // unsupported file for backing up - return nil + //TODO: check if the owner stays equal and then backup the file if the owner changes + // at this point we do modify the owner -> require backup + err = fsys.tryBackup(resolvedName) + if err != nil { + return err } + + return fsys.base.Lchown(name, uid, gid) } -// Create creates a file in the filesystem, returning the file and an -// error, if any happens. -func (fsys *BackupFS) Create(name string) (File, error) { - name, err := fsys.realPath(name) +// Rollback tries to rollback the backup back to the +// base system removing any new files for the base +// system and restoring any old files from the backup +// Best effort, any errors due to filesystem +// modification on the backup site are skipped +// This is a heavy weight operation which blocks the file system +// until the rollback is done. +func (fsys *BackupFS) Rollback() (multiErr error) { + defer func() { + if multiErr != nil { + multiErr = errors.Join(ErrRollbackFailed, multiErr) + } + }() + fsys.mu.Lock() + defer fsys.mu.Unlock() + + var ( + // these file sneed to be removed in a certain order, so we keep track of them + // from most nested to least nested files + // can be any file type, dir, file, symlink + removeBasePaths = make([]string, 0, 1) + + // these files also need to be restored in a certain order + // from least nested to most nested + restoreDirPaths = make([]string, 0, 4) + restoreFilePaths = make([]string, 0, 4) + restoreSymlinkPaths = make([]string, 0, 4) + + err error + exists bool + ) + + for path, info := range fsys.baseInfos { + if info == nil { + // file did not exist in the base filesystem at the point of + // filesystem modification. + _, exists, err = lexists(fsys.base, path) + if err != nil { + multiErr = errors.Join( + multiErr, + fmt.Errorf("failed to check whether file %s exists in base filesystem: %w", path, err), + ) + continue + } + + if exists { + // we will need to delete this file + removeBasePaths = append(removeBasePaths, path) + } + + // case where file must be removed in base file system + // finished + continue + } else if TrimVolume(path) == separator { + // skip root directory from restoration + continue + } + + mode := info.Mode() + switch { + case mode.IsDir(): + restoreDirPaths = append(restoreDirPaths, path) + case mode.IsRegular(): + restoreFilePaths = append(restoreFilePaths, path) + case mode&os.ModeSymlink != 0: + restoreSymlinkPaths = append(restoreSymlinkPaths, path) + default: + log.Printf("unknown file type: %s\n", path) + } + } + + err = fsys.tryRemoveBasePaths(removeBasePaths) if err != nil { - return nil, &os.PathError{Op: "create", Path: name, Err: fmt.Errorf("failed to clean path: %w", err)} + multiErr = errors.Join(err) } - err = fsys.tryBackup(name) + err = fsys.tryRestoreDirPaths(restoreDirPaths) if err != nil { - return nil, &os.PathError{Op: "create", Path: name, Err: fmt.Errorf("failed to backup path: %w", err)} + multiErr = errors.Join(multiErr, err) } - // create or truncate file - file, err := fsys.base.Create(name) + + err = fsys.tryRestoreFilePaths(restoreFilePaths) + if err != nil { + multiErr = errors.Join(multiErr, err) + } + + err = fsys.tryRestoreSymlinkPaths(restoreSymlinkPaths) + if err != nil { + multiErr = errors.Join(multiErr, err) + } + + // TODO: make this optional?: whether to delete the backup upon rollback + + // at this point we were able to restore all of the files + // now we need to delete our backup + err = fsys.tryRemoveBackupPaths("symlink", restoreSymlinkPaths) + if err != nil { + multiErr = errors.Join(multiErr, err) + } + + // delete files before directories in order for directories to be empty + err = fsys.tryRemoveBackupPaths("file", restoreFilePaths) + if err != nil { + multiErr = errors.Join(multiErr, err) + } + + // best effort deletion of backup files + // so we ignore the error + // we only delete directories that we did create. + // any user created content in directories is not touched + + err = fsys.tryRemoveBackupPaths("directory", restoreDirPaths) if err != nil { - return nil, &os.PathError{Op: "create", Path: name, Err: fmt.Errorf("create failed: %w", err)} + multiErr = errors.Join(multiErr, err) + } + + // in case of a multiError we are not able to restore the previous state anyway + // that is why we continue here to finish the rollback but at the same time inform + // the user about potential errors along the way. + + // at this point we have successfully restored our backup and + // removed all of the backup files and directories + + // now we can reset the internal data structure for book keeping of filesystem modifications + fsys.baseInfos = make(map[string]fs.FileInfo, 1) + return multiErr +} + +func (fsys *BackupFS) tryRemoveBasePaths(removeBasePaths []string) (multiErr error) { + var err error + // remove files from most nested to least nested + sort.Sort(ByMostFilePathSeparators(removeBasePaths)) + for _, remPath := range removeBasePaths { + // remove all files that were not there before the backup. + // ignore error, as this is a best effort restoration. + // folders and files did not exist in the first place + err = fsys.base.Remove(remPath) + if err != nil { + multiErr = errors.Join( + multiErr, + fmt.Errorf("failed to remove path in base filesystem %s: %w", remPath, err), + ) + } + } + return multiErr +} + +func (fsys *BackupFS) tryRemoveBackupPaths(fileType string, removeBackupPaths []string) (multiErr error) { + var ( + err error + found bool + ) + + // remove files from most nested to least nested + sort.Sort(ByMostFilePathSeparators(removeBackupPaths)) + for _, remPath := range removeBackupPaths { + _, found, err = lexists(fsys.backup, remPath) + if err != nil { + multiErr = errors.Join( + multiErr, + fmt.Errorf("failed to check whether %s exists in backup filesystem %s: %w", fileType, remPath, err), + ) + continue + } + + if !found { + // nothing to remove + continue + } + + // remove all files that were not there before the backup. + // WARNING: do not change this to RemoveAll, as we do not want to remove user created content + // in directories + err = fsys.backup.Remove(remPath) + if err != nil { + multiErr = errors.Join( + multiErr, + fmt.Errorf("failed to remove %s in backup filesystem %s: %w", fileType, remPath, err), + ) + } + } + return multiErr +} + +func (fsys *BackupFS) tryRestoreDirPaths(restoreDirPaths []string) (multiErr error) { + // in order to iterate over parent directories before child directories + sort.Sort(ByLeastFilePathSeparators(restoreDirPaths)) + var err error + for _, dirPath := range restoreDirPaths { + // backup -> base filesystem + err = copyDir(fsys.base, dirPath, fsys.baseInfos[dirPath]) + if err != nil { + multiErr = errors.Join(multiErr, err) + } + } + return multiErr +} + +func (fsys *BackupFS) tryRestoreSymlinkPaths(restoreSymlinkPaths []string) (multiErr error) { + // in this case it does not matter whether we sort the symlink paths or not + // we prefer to sort them in order to see potential errors better + sort.Strings(restoreSymlinkPaths) + var err error + for _, symlinkPath := range restoreSymlinkPaths { + err = restoreSymlink( + symlinkPath, + fsys.baseInfos[symlinkPath], + fsys.base, + fsys.backup, + ) + if err != nil { + // in this case it might make sense to retry the rollback + multiErr = errors.Join(multiErr, err) + } } - return file, nil -} -// Mkdir creates a directory in the filesystem, return an error if any -// happens. -func (fsys *BackupFS) Mkdir(name string, perm fs.FileMode) error { - name, err := fsys.realPath(name) - if err != nil { - return &os.PathError{Op: "mkdir", Path: name, Err: fmt.Errorf("failed to clean path: %w", err)} - } + return multiErr +} - err = fsys.tryBackup(name) - if err != nil { - return &os.PathError{Op: "mkdir", Path: name, Err: fmt.Errorf("failed to backup path: %w", err)} +func (fsys *BackupFS) tryRestoreFilePaths(restoreFilePaths []string) (multiErr error) { + // in this case it does not matter whether we sort the file paths or not + // we prefer to sort them in order to see potential errors better + sort.Strings(restoreFilePaths) + var err error + for _, filePath := range restoreFilePaths { + err = restoreFile(filePath, fsys.baseInfos[filePath], fsys.base, fsys.backup) + if err != nil { + // in this case it might make sense to retry the rollback + multiErr = errors.Join(multiErr, err) + } } - err = fsys.base.Mkdir(name, perm) - if err != nil { - return &os.PathError{Op: "mkdir", Path: name, Err: fmt.Errorf("mkdir failed: %w", err)} - } - return nil + return multiErr } -// MkdirAll creates a directory path and all -// parents that does not exist yet. -func (fsys *BackupFS) MkdirAll(name string, perm fs.FileMode) error { - name, err := fsys.realPath(name) - if err != nil { - return &os.PathError{Op: "mkdir_all", Path: name, Err: fmt.Errorf("failed to clean path: %w", err)} - } +// returns the cleaned path +func (fsys *BackupFS) realPath(name string) (resolvedName string, err error) { + return resolvePath(fsys, filepath.Clean(name)) +} - err = fsys.tryBackup(name) - if err != nil { - return &os.PathError{Op: "mkdir_all", Path: name, Err: fmt.Errorf("failed to backup path: %w", err)} - } +func (fsys *BackupFS) realPathWithFound(name string) (resolvedName string, found bool, err error) { + return resolvePathWithFound(fsys, filepath.Clean(name)) +} - err = fsys.base.MkdirAll(name, perm) - if err != nil { - return &os.PathError{Op: "mkdir_all", Path: name, Err: fmt.Errorf("mkdir_all failed: %w", err)} +// keeps track of files in the base filesystem. +// Files are saved only once, any consecutive update is ignored. +func (fsys *BackupFS) setInfoIfNotAlreadySeen(path string, info fs.FileInfo) { + _, found := fsys.baseInfos[path] + if !found { + fsys.baseInfos[path] = info } - return nil } -// Open opens a file, returning it or an error, if any happens. -// This returns a ready only file -func (fsys *BackupFS) Open(name string) (File, error) { - return fsys.OpenFile(name, os.O_RDONLY, 0) +func (fsys *BackupFS) alreadySeen(path string) bool { + _, found := fsys.baseInfos[path] + return found } -// OpenFile opens a file using the given flags and the given mode. -func (fsys *BackupFS) OpenFile(name string, flag int, perm fs.FileMode) (File, error) { - name, err := fsys.realPath(name) - if err != nil { - return nil, &os.PathError{Op: "open", Path: name, Err: fmt.Errorf("failed to clean path: %w", err)} - } +func (fsys *BackupFS) alreadySeenWithInfo(path string) (fs.FileInfo, bool) { + fi, found := fsys.baseInfos[path] + return fi, found +} - if flag == os.O_RDONLY { - // in read only mode the perm is not used. - f, err := fsys.base.OpenFile(name, os.O_RDONLY, 0) +func (fsys *BackupFS) tryRemoveBackup(resolvedName string) (err error) { + defer func() { if err != nil { - return nil, &os.PathError{Op: "open", Path: name, Err: fmt.Errorf("open failed: %w", err)} + err = &os.PathError{Op: "try_remove_backup", Path: resolvedName, Err: err} } - return f, nil - } - - // not read only opening -> backup - err = fsys.tryBackup(name) - if err != nil { - return nil, &os.PathError{Op: "open", Path: name, Err: fmt.Errorf("failed to backup path: %w", err)} - } - - file, err := fsys.base.OpenFile(name, flag, perm) - if err != nil { - return nil, &os.PathError{Op: "open", Path: name, Err: fmt.Errorf("open failed: %w", err)} - } - return file, nil -} - -// Remove removes a file identified by name, returning an error, if any -// happens. -func (fsys *BackupFS) Remove(name string) error { - name, err := fsys.realPath(name) - if err != nil { - return &os.PathError{Op: "remove", Path: name, Err: fmt.Errorf("failed to clean path: %w", err)} - } + }() - err = fsys.tryBackup(name) - if err != nil { - return &os.PathError{Op: "remove", Path: name, Err: fmt.Errorf("failed to backup path: %w", err)} + if !fsys.alreadySeen(resolvedName) { + // nothing to remove + return nil } - err = fsys.base.Remove(name) - if err != nil { - return &os.PathError{Op: "remove", Path: name, Err: fmt.Errorf("remove failed: %w", err)} + fi, err := fsys.backup.Lstat(resolvedName) + if err != nil && !isNotFoundError(err) { + return err } - return nil -} -// RemoveAll removes a directory path and any children it contains. It -// does not fail if the path does not exist (return nil). -// not supported -func (fsys *BackupFS) RemoveAll(name string) error { - name, err := fsys.realPath(name) - if err != nil { - return &os.PathError{Op: "remove_all", Path: name, Err: fmt.Errorf("failed to clean path: %w", err)} - } + // file not found + if fi == nil { + // nothing to remove, except internal state if it exists - // does not exist or no access, nothing to do - fi, err := fsys.Lstat(name) - if err != nil { - return &os.PathError{Op: "remove_all", Path: name, Err: err} + delete(fsys.baseInfos, resolvedName) + return nil } - // if it's a file or a symlink, directly remove it if !fi.IsDir() { - err = fsys.Remove(name) + // remove file or symlink + err := fsys.backup.Remove(resolvedName) if err != nil { - return &os.PathError{Op: "remove_all", Path: name, Err: fmt.Errorf("remove failed: %w", err)} + return err } + // only delete from internal state + // when file has been deleted + // this allows to retry the deletion attempt + delete(fsys.baseInfos, resolvedName) return nil } - directoryPaths := make([]string, 0, 1) + dirs := make([]string, 0) - err = Walk(fsys.base, name, func(path string, info fs.FileInfo, err error) error { + err = Walk(fsys.backup, resolvedName, func(path string, info fs.FileInfo, err error) (e error) { + // and then check for error if err != nil { return err } if info.IsDir() { - // initially we want to delete all files before we delete all of the directories - // but we also want to keep track of all found directories in order not to walk the - // dir tree again. - directoryPaths = append(directoryPaths, path) + // keep track of dirs + dirs = append(dirs, path) return nil } - return fsys.Remove(path) + // delete files + err = fsys.backup.Remove(path) + if err != nil { + return err + } + // delete dirs and files from internal map + // but only after re have removed the file successfully + delete(fsys.baseInfos, path) + return nil }) - if err != nil { - return &os.PathError{Op: "remove_all", Path: name, Err: fmt.Errorf("walkdir or remove failed: %w", err)} + return err } - // after deleting all of the files - //now we want to sort all of the file paths from the most - //nested file to the least nested file (count file path separators) - sort.Sort(ByMostFilePathSeparators(directoryPaths)) + sort.Sort(ByMostFilePathSeparators(dirs)) - for _, path := range directoryPaths { - err = fsys.Remove(path) + for _, dir := range dirs { + // remove directory and potential content which should not be there + err = fsys.backup.RemoveAll(dir) if err != nil { - return &os.PathError{Op: "remove_all", Path: name, Err: err} + return err } - } - - return nil -} - -// Rename renames a file. -func (fsys *BackupFS) Rename(oldname, newname string) error { - newname, err := fsys.realPath(newname) - if err != nil { - return &os.PathError{Op: "rename", Path: newname, Err: fmt.Errorf("failed to clean newname: %w", err)} - } - - oldname, err = fsys.realPath(oldname) - if err != nil { - return &os.PathError{Op: "rename", Path: oldname, Err: fmt.Errorf("failed to clean oldname: %w", err)} - } - - // make target file known - err = fsys.tryBackup(newname) - if err != nil { - return &os.PathError{Op: "rename", Path: newname, Err: fmt.Errorf("failed to backup newname: %w", err)} - } - - // there either was no previous file to be backed up - // but now we know that there was no file or there - // was a target file that has to be backed up which was then backed up - err = fsys.tryBackup(oldname) - if err != nil { - return &os.PathError{Op: "rename", Path: oldname, Err: fmt.Errorf("failed to backup oldname: %w", err)} + // delete directory from internal + // state only after it has been actually deleted + delete(fsys.baseInfos, dir) } - err = fsys.base.Rename(oldname, newname) - if err != nil { - return &os.PathError{Op: "rename", Path: oldname, Err: fmt.Errorf("renaming failed: %w", err)} - } return nil } -// Chmod changes the mode of the named file to mode. -func (fsys *BackupFS) Chmod(name string, mode fs.FileMode) error { - name, err := fsys.realPath(name) - if err != nil { - return &os.PathError{Op: "chmod", Path: name, Err: fmt.Errorf("failed to get clean path: %w", err)} - } - - err = fsys.tryBackup(name) - if err != nil { - return &os.PathError{Op: "chmod", Path: name, Err: fmt.Errorf("failed to backup path: %w", err)} - } - - err = fsys.base.Chmod(name, mode) - if err != nil { - return &os.PathError{Op: "chmod", Path: name, Err: fmt.Errorf("chmod failed: %w", err)} - } - return nil -} +func (fsys *BackupFS) tryBackup(resolvedName string) (err error) { + defer func() { + if err != nil { + err = &os.PathError{Op: "try_backup", Path: resolvedName, Err: err} + } + }() -// Chown changes the uid and gid of the named file. -func (fsys *BackupFS) Chown(name string, uid, gid int) error { - name, err := fsys.realPath(name) + info, needsBackup, err := fsys.backupRequired(resolvedName) if err != nil { - return &os.PathError{Op: "chown", Path: name, Err: fmt.Errorf("failed to clean path: %w", err)} + return err } - - err = fsys.tryBackup(name) - if err != nil { - return &os.PathError{Op: "chown", Path: name, Err: fmt.Errorf("failed to backup path: %w", err)} + if !needsBackup { + return nil } - err = fsys.base.Chown(name, uid, gid) - if err != nil { - return &os.PathError{Op: "chown", Path: name, Err: fmt.Errorf("chown failed: %w", err)} + dirPath := resolvedName + if !info.IsDir() { + // is file, get dir + dirPath = filepath.Dir(dirPath) } - return nil -} -// Chtimes changes the access and modification times of the named file -func (fsys *BackupFS) Chtimes(name string, atime, mtime time.Time) error { - name, err := fsys.realPath(name) + err = fsys.backupDirs(dirPath) if err != nil { - return &os.PathError{Op: "chtimes", Path: name, Err: fmt.Errorf("failed to clean path: %w", err)} + return err } - err = fsys.tryBackup(name) - if err != nil { - return &os.PathError{Op: "chtimes", Path: name, Err: fmt.Errorf("failed to backup path: %w", err)} - } - err = fsys.base.Chtimes(name, atime, mtime) - if err != nil { - return &os.PathError{Op: "chtimes", Path: name, Err: fmt.Errorf("chtimes failed: %w", err)} + fileMode := info.Mode() + switch { + case fileMode.IsDir(): + // file was actually a directory, + // we did already backup all of the directory tree + return nil + case fileMode.IsRegular(): + // name was a path to a file + // create the file + sf, err := fsys.base.Open(resolvedName) + if err != nil { + return err + } + defer sf.Close() + err = copyFile(fsys.backup, resolvedName, info, sf) + if err != nil { + return err + } + fsys.setInfoIfNotAlreadySeen(resolvedName, info) + return nil + case fileMode&os.ModeSymlink != 0: + // symlink + err = copySymlink( + fsys.base, + fsys.backup, + resolvedName, + info, + ) + if err != nil { + return err + } + fsys.setInfoIfNotAlreadySeen(resolvedName, info) + return nil + default: + // unsupported file for backing up + return nil } - return nil } -func (fsys *BackupFS) Lstat(name string) (fs.FileInfo, error) { - fsys.mu.Lock() - defer fsys.mu.Unlock() - - return fsys.lstat(name) -} +// this method does not need to care about symlinks because it is passed a resolved path already which +// doe snot contain any directores that are symlinks +// resolvedDirPath MUST BE a directory +func (fsys *BackupFS) backupDirs(resolvedDirPath string) (err error) { + _, err = IterateDirTree(resolvedDirPath, func(resolvedSubDirPath string) (bool, error) { + // when the passed path is resolved, the subdir paths are implicitly also already resolved. -// lstat has the same logic as stat -func (fsys *BackupFS) lstat(name string) (fs.FileInfo, error) { - name, err := fsys.realPath(name) - if err != nil { - return nil, err - } + // this should prevent infinite recursion due to circular symlinks + fi, required, err := fsys.backupRequired(resolvedSubDirPath) + if err != nil { + return false, err + } - // we want to check all parent directories before we check the actual file. - // in order to keep track of their state as well. - // /root -> /root/sub/ -> /root/sub/sub1 - // iterate parent directories and keep track of their initial state. - _, _ = IterateDirTree(filepath.Dir(name), func(subdirPath string) (bool, error) { - if fsys.alreadyFoundBaseInfo(subdirPath) { + if !required { return true, nil } - // only in the case that we do not know the subdirectory already - // we do want to track the initial state of the sub directory. - // if it does not exist, it should not exist - _, err = fsys.trackedLstat(subdirPath) + // is a directory, backup the directory + err = copyDir(fsys.backup, resolvedSubDirPath, fi) if err != nil { - // in case of an error we want to fail fast - return false, nil + return false, err } + fsys.setInfoIfNotAlreadySeen(resolvedSubDirPath, fi) + return true, nil }) - - // check is actual file exists - fi, err := fsys.trackedLstat(name) - if err != nil { - return nil, err - } - - return fi, nil -} - -// trackedLstat has the same logic as trackedStat but it uses Lstat instead, in case that is possible. -func (fsys *BackupFS) trackedLstat(name string) (fs.FileInfo, error) { - - fi, err := fsys.base.Lstat(name) - - // keep track of initial - if err != nil { - if oerr, ok := err.(*os.PathError); ok { - if oerr.Err == fs.ErrNotExist || oerr.Err == syscall.ENOENT || oerr.Err == syscall.ENOTDIR { - // file or symlink does not exist - fsys.setBaseInfoIfNotFound(name, nil) - return nil, err - } - } else if err == syscall.ENOENT { - // file or symlink does not exist - fsys.setBaseInfoIfNotFound(name, nil) - return nil, err - } - return nil, err - } - - fsys.setBaseInfoIfNotFound(name, fi) - return fi, nil -} - -// Symlink changes the access and modification times of the named file -func (fsys *BackupFS) Symlink(oldname, newname string) error { - oldname, err := fsys.realPath(oldname) - if err != nil { - return &os.LinkError{Op: "symlink", Old: oldname, New: newname, Err: fmt.Errorf("failed to clean oldname: %w", err)} - } - - newname, err = fsys.realPath(newname) - if err != nil { - return &os.LinkError{Op: "symlink", Old: oldname, New: newname, Err: fmt.Errorf("failed to clean newname: %w", err)} - } - - // we only want to backup the newname, - // as seemingly the new name is the target symlink location - // the old file path should not have been modified - - // in case we fail to backup the symlink, we return an error - err = fsys.tryBackup(newname) - if err != nil { - return &os.LinkError{Op: "symlink", Old: oldname, New: newname, Err: fmt.Errorf("failed to backup newname: %w", err)} - } - - err = fsys.base.Symlink(oldname, newname) if err != nil { - return &os.LinkError{Op: "symlink", Old: oldname, New: newname, Err: fmt.Errorf("symlink failed: %w", err)} + return &os.PathError{Op: "backup_dirs", Path: resolvedDirPath, Err: err} } return nil } -func (fsys *BackupFS) Readlink(name string) (string, error) { - name, err := fsys.realPath(name) - if err != nil { - return "", &os.PathError{Op: "readlink", Path: name, Err: fmt.Errorf("failed to clean path: %w", err)} - } - - path, err := fsys.base.Readlink(name) - if err != nil { - return "", &os.PathError{Op: "readlink", Path: name, Err: fmt.Errorf("readlink failed: %w", err)} - } - return path, nil -} +// backupRequired checks whether a file that is about to be changed needs to be backed up. +// files that do not exist in the BackupFS need to be backed up. +// files that do exist in the BackupFS either as files or in the baseInfos map as non-existing files +// do not need to be backed up (again) +func (fsys *BackupFS) backupRequired(resolvedName string) (info fs.FileInfo, required bool, err error) { -// Lchown does not fallback to chown. It does return an error in case that lchown cannot be called. -func (fsys *BackupFS) Lchown(name string, uid, gid int) error { - name, err := fsys.realPath(name) - if err != nil { - return &os.PathError{Op: "lchown", Path: name, Err: fmt.Errorf("failed to clean path: %w", err)} + info, found := fsys.alreadySeenWithInfo(resolvedName) + if found { + // base infos is the truth, if nothing is found, nothing needs to be backed up + return info, false, nil } - //TODO: check if the owner stays equal and then backup the file if the owner changes - // at this point we do modify the owner -> require backup - err = fsys.tryBackup(name) - if err != nil { - return &os.PathError{Op: "lchown", Path: name, Err: fmt.Errorf("failed to backup path: %w", err)} + // fill fsys.baseInfos + // of symlink, file & directory as well as their parent directories. + info, err = fsys.Lstat(resolvedName) + if isNotFoundError(err) { + fsys.setInfoIfNotAlreadySeen(resolvedName, nil) + // not found, no backup needed + return nil, false, nil + } else if err != nil { + return nil, false, err } - return fsys.base.Lchown(name, uid, gid) + return info, true, nil } diff --git a/backupfs_json.go b/backupfs_json.go new file mode 100644 index 0000000..8b71ed6 --- /dev/null +++ b/backupfs_json.go @@ -0,0 +1,47 @@ +package backupfs + +import ( + "io/fs" + "path" + "path/filepath" + "time" +) + +func toFInfo(path string, fi fs.FileInfo) *fInfo { + return &fInfo{ + FileName: filepath.ToSlash(path), + FileMode: uint32(fi.Mode()), + FileModTime: fi.ModTime().UnixNano(), + FileSize: fi.Size(), + FileUid: toUID(fi), + FileGid: toGID(fi), + } +} + +type fInfo struct { + FileName string `json:"name"` + FileMode uint32 `json:"mode"` + FileModTime int64 `json:"mod_time"` + FileSize int64 `json:"size"` + FileUid int `json:"uid"` + FileGid int `json:"gid"` +} + +func (fi *fInfo) Name() string { + return path.Base(fi.FileName) +} +func (fi *fInfo) Size() int64 { + return fi.FileSize +} +func (fi *fInfo) Mode() fs.FileMode { + return fs.FileMode(fi.FileMode) +} +func (fi *fInfo) ModTime() time.Time { + return time.Unix(fi.FileModTime/1000000000, fi.FileModTime%1000000000) +} +func (fi *fInfo) IsDir() bool { + return fi.Mode().IsDir() +} +func (fi *fInfo) Sys() interface{} { + return nil +} diff --git a/backupfs_ready_only.go b/backupfs_ready_only.go new file mode 100644 index 0000000..305f1a8 --- /dev/null +++ b/backupfs_ready_only.go @@ -0,0 +1,48 @@ +package backupfs + +import ( + "io/fs" + "os" +) + +func (fsys *BackupFS) Lstat(name string) (fi fs.FileInfo, err error) { + defer func() { + if err != nil { + err = &os.PathError{Op: "lstat", Path: name, Err: err} + } + }() + + return fsys.base.Lstat(name) +} + +// Stat returns a FileInfo describing the named file, or an error, if any happens. +// Stat only looks at the base filesystem and returns the stat of the files at the specified path +func (fsys *BackupFS) Stat(name string) (_ fs.FileInfo, err error) { + defer func() { + if err != nil { + err = &os.PathError{Op: "stat", Path: name, Err: err} + } + }() + + return fsys.base.Stat(name) +} + +func (fsys *BackupFS) Readlink(name string) (_ string, err error) { + defer func() { + if err != nil { + err = &os.PathError{Op: "readlink", Path: name, Err: err} + } + }() + + path, err := fsys.base.Readlink(name) + if err != nil { + return "", err + } + return path, nil +} + +// Open opens a file, returning it or an error, if any happens. +// This returns a ready only file +func (fsys *BackupFS) Open(name string) (File, error) { + return fsys.OpenFile(name, os.O_RDONLY, 0) +} diff --git a/backupfs_test.go b/backupfs_test.go index f71afc3..6230be6 100644 --- a/backupfs_test.go +++ b/backupfs_test.go @@ -828,6 +828,7 @@ func TestRemoveDirInSymlinkDir(t *testing.T) { originalFilePath = path.Join(originalSubDir, "test.txt") originalFileContent = "test_content" symlinkDir = "/lib" + symlinkSubDir = "/lib/systemd" ) // prepare existing files @@ -838,7 +839,7 @@ func TestRemoveDirInSymlinkDir(t *testing.T) { backupFsState := createFSState(t, backup, "/") // try creating the directory tree ober a symlinked folder - removeAll(t, backupFS, filepath.Join(symlinkDir, "systemd")) + removeAll(t, backupFS, symlinkSubDir) err := backupFS.Rollback() require.NoError(t, err)