package pathlib import ( "fmt" "io" "os" "path/filepath" "strings" "time" "github.com/spf13/afero" ) // Path is an object that represents a path type Path struct { path string fs afero.Fs // DefaultFileMode is the mode that is used when creating new files in functions // that do not accept os.FileMode as a parameter. DefaultFileMode os.FileMode // DefaultDirMode is the mode that will be used when creating new directories DefaultDirMode os.FileMode // Sep is the seperator used in path calculations. By default this is set to // os.PathSeparator. Sep string } type PathOpts func(p *Path) func PathWithAfero(fs afero.Fs) PathOpts { return func(p *Path) { p.fs = fs } } func PathWithSeperator(sep string) PathOpts { return func(p *Path) { p.Sep = sep } } // NewPath returns a new OS path func NewPath(path string, opts ...PathOpts) *Path { p := &Path{ path: path, fs: afero.NewOsFs(), DefaultFileMode: DefaultFileMode, DefaultDirMode: DefaultDirMode, Sep: string(os.PathSeparator), } for _, opt := range opts { opt(p) } return p } // NewPathAfero returns a Path object with the given Afero object // // Deprecated: Use the PathWithAfero option in Newpath instead. func NewPathAfero(path string, fs afero.Fs) *Path { return NewPath(path, PathWithAfero(fs)) } // Glob returns all of the path objects matched by the given pattern // inside of the afero filesystem. func Glob(fs afero.Fs, pattern string) ([]*Path, error) { matches, err := afero.Glob(fs, pattern) if err != nil { return nil, fmt.Errorf("failed to glob: %w", err) } pathMatches := []*Path{} for _, match := range matches { pathMatches = append(pathMatches, NewPathAfero(match, fs)) } return pathMatches, nil } type namer interface { Name() string } func getFsName(fs afero.Fs) string { if name, ok := fs.(namer); ok { return name.Name() } return "" } // Fs returns the internal afero.Fs object. func (p *Path) Fs() afero.Fs { return p.fs } func (p *Path) doesNotImplementErr(interfaceName string) error { return doesNotImplementErr(interfaceName, p.Fs()) } func doesNotImplementErr(interfaceName string, fs afero.Fs) error { return fmt.Errorf("%w: Path's afero filesystem %s does not implement %s", ErrDoesNotImplement, getFsName(fs), interfaceName) } func (p *Path) lstatNotPossible() error { return lstatNotPossible(p.Fs()) } func lstatNotPossible(fs afero.Fs) error { return fmt.Errorf("%w: Path's afero filesystem %s does not support lstat", ErrLstatNotPossible, getFsName(fs)) } // ******************************* // * afero.Fs wrappers * // ******************************* // Create creates a file if possible, returning the file and an error, if any happens. func (p *Path) Create() (File, error) { file, err := p.Fs().Create(p.String()) return File{file}, err } // Mkdir makes the current dir. If the parents don't exist, an error // is returned. func (p *Path) Mkdir() error { return p.Fs().Mkdir(p.String(), p.DefaultDirMode) } // MkdirMode makes the current dir. If the parents don't exist, an error // is returned. func (p *Path) MkdirMode(perm os.FileMode) error { return p.Fs().Mkdir(p.String(), perm) } // MkdirAll makes all of the directories up to, and including, the given path. func (p *Path) MkdirAll() error { return p.Fs().MkdirAll(p.String(), p.DefaultDirMode) } // MkdirAllMode makes all of the directories up to, and including, the given path. func (p *Path) MkdirAllMode(perm os.FileMode) error { return p.Fs().MkdirAll(p.String(), perm) } // Open opens a file for read-only, returning it or an error, if any happens. func (p *Path) Open() (*File, error) { handle, err := p.Fs().Open(p.String()) return &File{ File: handle, }, err } // OpenFile opens a file using the given flags. // See the list of flags at: https://golang.org/pkg/os/#pkg-constants func (p *Path) OpenFile(flag int) (*File, error) { handle, err := p.Fs().OpenFile(p.String(), flag, p.DefaultFileMode) return &File{ File: handle, }, err } // OpenFileMode opens a file using the given flags and the given mode. // See the list of flags at: https://golang.org/pkg/os/#pkg-constants func (p *Path) OpenFileMode(flag int, perm os.FileMode) (*File, error) { handle, err := p.Fs().OpenFile(p.String(), flag, perm) return &File{ File: handle, }, err } // Remove removes a file, returning an error, if any // happens. func (p *Path) Remove() error { return p.Fs().Remove(p.String()) } // RemoveAll removes the given path and all of its children. func (p *Path) RemoveAll() error { return p.Fs().RemoveAll(p.String()) } // RenameStr renames a file func (p *Path) RenameStr(newname string) error { if err := p.Fs().Rename(p.String(), newname); err != nil { return err } // Rename succeeded. Set our path to the newname. p.path = newname return nil } // Rename renames a file func (p *Path) Rename(target *Path) error { return p.RenameStr(target.String()) } // Stat returns the os.FileInfo of the given path func (p *Path) Stat() (os.FileInfo, error) { return p.Fs().Stat(p.String()) } // Chmod changes the file mode of the given path func (p *Path) Chmod(mode os.FileMode) error { return p.Fs().Chmod(p.String(), mode) } // Chtimes changes the modification and access time of the given path. func (p *Path) Chtimes(atime time.Time, mtime time.Time) error { return p.Fs().Chtimes(p.String(), atime, mtime) } // ************************ // * afero.Afero wrappers * // ************************ // DirExists returns whether or not the path represents a directory that exists func (p *Path) DirExists() (bool, error) { return afero.DirExists(p.Fs(), p.String()) } // Exists returns whether the path exists func (p *Path) Exists() (bool, error) { return afero.Exists(p.Fs(), p.String()) } // FileContainsAnyBytes returns whether or not the path contains // any of the listed bytes. func (p *Path) FileContainsAnyBytes(subslices [][]byte) (bool, error) { return afero.FileContainsAnyBytes(p.Fs(), p.String(), subslices) } // FileContainsBytes returns whether or not the given file contains the bytes func (p *Path) FileContainsBytes(subslice []byte) (bool, error) { return afero.FileContainsBytes(p.Fs(), p.String(), subslice) } // IsDir checks if a given path is a directory. func (p *Path) IsDir() (bool, error) { return afero.IsDir(p.Fs(), p.String()) } // IsDir returns whether or not the os.FileMode object represents a // directory. func IsDir(mode os.FileMode) bool { return mode.IsDir() } // IsEmpty checks if a given file or directory is empty. func (p *Path) IsEmpty() (bool, error) { return afero.IsEmpty(p.Fs(), p.String()) } // ReadDir reads the current path and returns a list of the corresponding // Path objects. This function differs from os.Readdir in that it does // not call Stat() on the files. Instead, it calls Readdirnames which // is less expensive and does not force the caller to make expensive // Stat calls. func (p *Path) ReadDir() ([]*Path, error) { paths := []*Path{} handle, err := p.Open() if err != nil { return paths, err } children, err := handle.Readdirnames(-1) if err != nil { return paths, err } for _, child := range children { paths = append(paths, p.Join(child)) } return paths, err } // ReadFile reads the given path and returns the data. If the file doesn't exist // or is a directory, an error is returned. func (p *Path) ReadFile() ([]byte, error) { return afero.ReadFile(p.Fs(), p.String()) } // SafeWriteReader is the same as WriteReader but checks to see if file/directory already exists. func (p *Path) SafeWriteReader(r io.Reader) error { return afero.SafeWriteReader(p.Fs(), p.String(), r) } // WriteFileMode writes the given data to the path (if possible). If the file exists, // the file is truncated. If the file is a directory, or the path doesn't exist, // an error is returned. func (p *Path) WriteFileMode(data []byte, perm os.FileMode) error { return afero.WriteFile(p.Fs(), p.String(), data, perm) } // WriteFile writes the given data to the path (if possible). If the file exists, // the file is truncated. If the file is a directory, or the path doesn't exist, // an error is returned. func (p *Path) WriteFile(data []byte) error { return afero.WriteFile(p.Fs(), p.String(), data, p.DefaultFileMode) } // WriteReader takes a reader and writes the content func (p *Path) WriteReader(r io.Reader) error { return afero.WriteReader(p.Fs(), p.String(), r) } // ************************************* // * pathlib.Path-like implementations * // ************************************* // Name returns the string representing the final path component func (p *Path) Name() string { return filepath.Base(p.path) } // Parent returns the Path object of the parent directory func (p *Path) Parent() *Path { return NewPathAfero(filepath.Dir(p.String()), p.Fs()) } // Readlink returns the target path of a symlink. // // This will fail if the underlying afero filesystem does not implement // afero.LinkReader. func (p *Path) Readlink() (*Path, error) { linkReader, ok := p.Fs().(afero.LinkReader) if !ok { return nil, p.doesNotImplementErr("afero.LinkReader") } resolvedPathStr, err := linkReader.ReadlinkIfPossible(p.path) if err != nil { return nil, err } return NewPathAfero(resolvedPathStr, p.fs), nil } func resolveIfSymlink(path *Path) (*Path, bool, error) { isSymlink, err := path.IsSymlink() if err != nil { return path, isSymlink, err } if isSymlink { resolvedPath, err := path.Readlink() if err != nil { // Return the path unchanged on errors return path, isSymlink, err } return resolvedPath, isSymlink, nil } return path, isSymlink, nil } func resolveAllHelper(path *Path) (*Path, error) { parts := path.Parts() for i := 0; i < len(parts); i++ { rightOfComponent := parts[i+1:] upToComponent := parts[:i+1] componentPath := NewPathAfero(strings.Join(upToComponent, path.Sep), path.Fs()) resolved, isSymlink, err := resolveIfSymlink(componentPath) if err != nil { return path, err } if isSymlink { if resolved.IsAbsolute() { return resolveAllHelper(resolved.Join(strings.Join(rightOfComponent, path.Sep))) } return resolveAllHelper(componentPath.Parent().JoinPath(resolved).Join(rightOfComponent...)) } } // If we get through the entire iteration above, that means no component was a symlink. // Return the argument. return path, nil } // ResolveAll canonicalizes the path by following every symlink in // every component of the given path recursively. The behavior // should be identical to the `readlink -f` command from POSIX OSs. // This will fail if the underlying afero filesystem does not implement // afero.LinkReader. The path will be returned unchanged on errors. func (p *Path) ResolveAll() (*Path, error) { return resolveAllHelper(p) } // Parts returns the individual components of a path func (p *Path) Parts() []string { parts := []string{} if p.IsAbsolute() { parts = append(parts, p.Sep) } normalizedPathStr := normalizePathString(p.String()) normalizedParts := normalizePathParts(strings.Split(normalizedPathStr, p.Sep)) return append(parts, normalizedParts...) } // IsAbsolute returns whether or not the path is an absolute path. This is // determined by checking if the path starts with a slash. func (p *Path) IsAbsolute() bool { return strings.HasPrefix(p.path, "/") } // Join joins the current object's path with the given elements and returns // the resulting Path object. func (p *Path) Join(elems ...string) *Path { paths := []string{p.path} paths = append(paths, elems...) return NewPathAfero(strings.Join(paths, p.Sep), p.Fs()) } // JoinPath is the same as Join() except it accepts a path object func (p *Path) JoinPath(path *Path) *Path { return p.Join(path.Parts()...) } func normalizePathString(path string) string { path = strings.TrimSpace(path) path = strings.TrimPrefix(path, "./") path = strings.TrimRight(path, " ") if len(path) > 1 { path = strings.TrimSuffix(path, "/") } return path } func normalizePathParts(path []string) []string { // We might encounter cases where path represents a split of the path // "///" etc. We will get a bunch of erroneous empty strings in such a split, // so remove all of the trailing empty strings except for the first one (if any) normalized := []string{} for i := 0; i < len(path); i++ { if path[i] != "" { normalized = append(normalized, path[i]) } } return normalized } // RelativeTo computes a relative version of path to the other path. For instance, // if the object is /path/to/foo.txt and you provide /path/ as the argment, the // returned Path object will represent to/foo.txt. func (p *Path) RelativeTo(other *Path) (*Path, error) { thisPathNormalized := normalizePathString(p.String()) otherPathNormalized := normalizePathString(other.String()) thisParts := p.Parts() otherParts := other.Parts() var relativeBase int for idx, part := range otherParts { if idx >= len(thisParts) || thisParts[idx] != part { return p, fmt.Errorf("%s does not start with %s: %w", thisPathNormalized, otherPathNormalized, ErrRelativeTo) } relativeBase = idx } relativePath := thisParts[relativeBase+1:] if len(relativePath) == 0 || (len(relativePath) == 1 && relativePath[0] == "") { relativePath = []string{"."} } return NewPathAfero(strings.Join(relativePath, "/"), p.Fs()), nil } // RelativeToStr computes a relative version of path to the other path. For instance, // if the object is /path/to/foo.txt and you provide /path/ as the argment, the // returned Path object will represent to/foo.txt. func (p *Path) RelativeToStr(other string) (*Path, error) { return p.RelativeTo(NewPathAfero(other, p.Fs())) } // Lstat lstat's the path if the underlying afero filesystem supports it. If // the filesystem does not support afero.Lstater, or if the filesystem implements // afero.Lstater but returns false for the "lstat called" return value. // // A nil os.FileInfo is returned on errors. func (p *Path) Lstat() (os.FileInfo, error) { lStater, ok := p.Fs().(afero.Lstater) if !ok { return nil, p.doesNotImplementErr("afero.Lstater") } stat, lstatCalled, err := lStater.LstatIfPossible(p.String()) if !lstatCalled && err == nil { return nil, p.lstatNotPossible() } return stat, err } // SymlinkStr symlinks to the target location. This will fail if the underlying // afero filesystem does not implement afero.Linker. func (p *Path) SymlinkStr(target string) error { return p.Symlink(NewPathAfero(target, p.Fs())) } // Symlink symlinks to the target location. This will fail if the underlying // afero filesystem does not implement afero.Linker. func (p *Path) Symlink(target *Path) error { symlinker, ok := p.fs.(afero.Linker) if !ok { return p.doesNotImplementErr("afero.Linker") } return symlinker.SymlinkIfPossible(target.path, p.path) } // String returns the string representation of the path func (p *Path) String() string { return p.path } // IsFile returns true if the given path is a file. func (p *Path) IsFile() (bool, error) { fileInfo, err := p.Stat() if err != nil { return false, err } return IsFile(fileInfo.Mode()), nil } // IsFile returns whether or not the file described by the given // os.FileMode is a regular file. func IsFile(mode os.FileMode) bool { return mode.IsRegular() } // IsSymlink returns true if the given path is a symlink. // Fails if the filesystem doesn't implement afero.Lstater. func (p *Path) IsSymlink() (bool, error) { fileInfo, err := p.Lstat() if err != nil { return false, err } return IsSymlink(fileInfo.Mode()), nil } // IsSymlink returns true if the file described by the given // os.FileMode describes a symlink. func IsSymlink(mode os.FileMode) bool { return mode&os.ModeSymlink != 0 } // DeepEquals returns whether or not the path pointed to by other // has the same resolved filepath as self. func (p *Path) DeepEquals(other *Path) (bool, error) { selfResolved, err := p.ResolveAll() if err != nil { return false, err } otherResolved, err := other.ResolveAll() if err != nil { return false, err } return selfResolved.Clean().Equals(otherResolved.Clean()), nil } // Equals returns whether or not the object's path is identical // to other's, in a shallow sense. It simply checks for equivalence // in the unresolved Paths() of each object. func (p *Path) Equals(other *Path) bool { return p.String() == other.String() } // GetLatest returns the file or directory that has the most recent mtime. Only // works if this path is a directory and it exists. If the directory is empty, // the returned Path object will be nil. func (p *Path) GetLatest() (*Path, error) { files, err := p.ReadDir() if err != nil { return nil, err } var greatestFileSeen *Path for _, file := range files { if greatestFileSeen == nil { greatestFileSeen = p.Join(file.Name()) } greatestMtime, err := greatestFileSeen.Mtime() if err != nil { return nil, err } thisMtime, err := file.Mtime() // There is a possible race condition where the file is deleted after // our call to ReadDir. We throw away the error if it isn't // os.ErrNotExist if err != nil && !os.IsNotExist(err) { return nil, err } if thisMtime.After(greatestMtime) { greatestFileSeen = p.Join(file.Name()) } } return greatestFileSeen, nil } // Glob returns all matches of pattern relative to this object's path. func (p *Path) Glob(pattern string) ([]*Path, error) { return Glob(p.Fs(), p.Join(pattern).String()) } // Clean returns a new object that is a lexically-cleaned // version of Path. func (p *Path) Clean() *Path { return NewPathAfero(filepath.Clean(p.String()), p.Fs()) } // Mtime returns the modification time of the given path. func (p *Path) Mtime() (time.Time, error) { stat, err := p.Stat() if err != nil { return time.Time{}, err } return Mtime(stat) } // Copy copies the path to another path using io.Copy. // Returned is the number of bytes copied and any error values. // The destination file is truncated if it exists, and is created // if it does not exist. func (p *Path) Copy(other *Path) (int64, error) { srcFile, err := p.Open() if err != nil { return 0, fmt.Errorf("opening source file: %w", err) } defer srcFile.Close() dstFile, err := other.OpenFile(os.O_TRUNC | os.O_CREATE | os.O_WRONLY) if err != nil { return 0, fmt.Errorf("opening destination file: %w", err) } defer dstFile.Close() return io.Copy(dstFile, srcFile) } // Mtime returns the mtime described in the given os.FileInfo object func Mtime(fileInfo os.FileInfo) (time.Time, error) { return fileInfo.ModTime(), nil } // Size returns the size of the object. Fails if the object doesn't exist. func (p *Path) Size() (int64, error) { stat, err := p.Stat() if err != nil { return 0, err } return Size(stat), nil } // Size returns the size described by the os.FileInfo. Before you say anything, // yes... you could just do fileInfo.Size(). This is purely a convenience function // to create API consistency. func Size(fileInfo os.FileInfo) int64 { return fileInfo.Size() }