Gopher2600/archivefs/path.go
2024-04-17 15:59:17 +01:00

291 lines
6.4 KiB
Go

// This file is part of Gopher2600.
//
// Gopher2600 is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Gopher2600 is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Gopher2600. If not, see <https://www.gnu.org/licenses/>.
package archivefs
import (
"archive/zip"
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
)
// Node represents a single part of a full path
type Node struct {
Name string
// a directory has the the field of IsDir set to true
IsDir bool
// a recognised archive file has InArchive set to true. note that an archive
// file is also considered to be directory
IsArchive bool
}
func (e Node) String() string {
return e.Name
}
// Path represents a single destination in the file system
type Path struct {
current string
isDir bool
zf *zip.ReadCloser
// if the path is inside a zip file, we split the in-zip path into the path
// to a file and the file itself
inZipPath string
inZipFile string
}
// String returns the current path
func (afs Path) String() string {
return afs.current
}
// Base returns the last element of the current path
func (afs Path) Base() string {
return filepath.Base(afs.current)
}
// Dir returns all but the last element of path
func (afs Path) Dir() string {
if afs.isDir {
return afs.current
}
return filepath.Dir(afs.current)
}
// IsDir returns true if Path is currently set to a directory. For the purposes
// of archivefs, the root of an archive is treated as a directory
func (afs Path) IsDir() bool {
return afs.isDir
}
// InArchive returns true if path is currently inside an archive
func (afs Path) InArchive() bool {
return afs.zf != nil
}
// Open and return an io.ReadSeeker for the filename previously set by the Set()
// function.
//
// Returns the io.ReadSeeker, the size of the data behind the ReadSeeker and any
// errors.
func (afs Path) Open() (io.ReadSeeker, int, error) {
if afs.zf != nil {
f, err := afs.zf.Open(filepath.Join(afs.inZipPath, afs.inZipFile))
if err != nil {
return nil, 0, err
}
defer f.Close()
b, err := io.ReadAll(f)
if err != nil {
return nil, 0, err
}
return bytes.NewReader(b), len(b), nil
}
f, err := os.Open(afs.current)
if err != nil {
return nil, 0, err
}
info, err := f.Stat()
if err != nil {
return nil, 0, err
}
return f, int(info.Size()), nil
}
// Close any open zip files and reset path
func (afs *Path) Close() {
afs.current = ""
afs.isDir = false
afs.inZipPath = ""
afs.inZipFile = ""
if afs.zf != nil {
afs.zf.Close()
afs.zf = nil
}
}
// List returns the child entries for the current path location. If the current
// path is a file then the list will be the contents of the containing directory
// of that file
func (afs *Path) List() ([]Node, error) {
var ent []Node
if afs.zf != nil {
for _, f := range afs.zf.File {
// split file name into parts. the list is joined together again
// below to create the path to the file. this is better than
// filepath.Dir() because that will add path components that make it
// awkward to compare with afs.inZipPath
flst := strings.Split(filepath.Clean(f.Name), string(filepath.Separator))
fdir := filepath.Join(flst[:len(flst)-1]...)
// if path to the file is not the same as inZipPath then continue
// with the next file
if fdir != afs.inZipPath {
continue
}
fi := f.FileInfo()
if fi.IsDir() {
ent = append(ent, Node{
Name: fi.Name(),
IsDir: true,
})
} else {
ent = append(ent, Node{
Name: fi.Name(),
})
}
}
} else {
path := afs.current
if !afs.isDir {
path = filepath.Dir(path)
}
dir, err := os.ReadDir(path)
if err != nil {
return []Node{}, fmt.Errorf("archivefs: entries: %w", err)
}
for _, d := range dir {
// using os.Stat() to get file information otherwise links to
// directories do not have the IsDir() property
fi, err := os.Stat(filepath.Join(path, d.Name()))
if err != nil {
continue
}
if fi.IsDir() {
ent = append(ent, Node{
Name: d.Name(),
IsDir: true,
})
} else {
p := filepath.Join(path, d.Name())
_, err := zip.OpenReader(p)
if err == nil {
ent = append(ent, Node{
Name: d.Name(),
IsDir: true,
IsArchive: true,
})
} else {
ent = append(ent, Node{
Name: d.Name(),
})
}
}
}
}
// sort so that directories are at the start of the list
sort.Slice(ent, func(i int, j int) bool {
return ent[i].IsDir
})
// sort alphabetically (case insensitive)
sort.SliceStable(ent, func(i int, j int) bool {
return strings.ToLower(ent[i].Name) < strings.ToLower(ent[j].Name)
})
return ent, nil
}
func (afs *Path) Set(path string) error {
afs.Close()
// clean path and split into parts
path = filepath.Clean(path)
lst := strings.Split(path, string(filepath.Separator))
// strings.Split will remove a leading filepath.Separator. we need to add
// one back so that filepath.Join() works as expected
if lst[0] == "" {
lst[0] = string(filepath.Separator)
}
// reuse path string
path = ""
for _, l := range lst {
path = filepath.Join(path, l)
if afs.zf != nil {
p := filepath.Join(afs.inZipPath, l)
zf, err := afs.zf.Open(p)
if err != nil {
return fmt.Errorf("archivefs: set: %v", err)
}
zfi, err := zf.Stat()
if err != nil {
return fmt.Errorf("archivefs: set: %v", err)
}
afs.isDir = zfi.IsDir()
if afs.isDir {
afs.inZipPath = p
afs.inZipFile = ""
} else {
afs.inZipFile = l
}
} else {
fi, err := os.Stat(path)
if err != nil {
return fmt.Errorf("archivefs: set: %v", err)
}
afs.isDir = fi.IsDir()
if afs.isDir {
continue
}
afs.zf, err = zip.OpenReader(path)
if err == nil {
// the root of an archive file is considered to be a directory
afs.isDir = true
continue
}
if !errors.Is(err, zip.ErrFormat) {
return fmt.Errorf("archivefs: set: %v", err)
}
}
}
// make sure path is clean
afs.current = filepath.Clean(path)
return nil
}