Implement milestone 6b workbench routing skeleton
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
func NormalizeRelativeDir(relativeDir string) (string, error) {
|
||||
return normalizeRelativePath(relativeDir, true)
|
||||
}
|
||||
|
||||
func NormalizeRelativeFile(relativePath string) (string, error) {
|
||||
return normalizeRelativePath(relativePath, false)
|
||||
}
|
||||
|
||||
func IsReservedPath(relativePath string) bool {
|
||||
normalized := strings.ReplaceAll(relativePath, "\\", "/")
|
||||
cleaned := path.Clean(normalized)
|
||||
if cleaned == "." {
|
||||
cleaned = ""
|
||||
}
|
||||
if cleaned == "" {
|
||||
return false
|
||||
}
|
||||
first := strings.Split(cleaned, "/")[0]
|
||||
return strings.EqualFold(first, ".verstak")
|
||||
}
|
||||
|
||||
func normalizeRelativePath(input string, allowRoot bool) (string, error) {
|
||||
if strings.Contains(input, "\x00") {
|
||||
return "", fmt.Errorf("invalid-path: null-byte")
|
||||
}
|
||||
if strings.Contains(input, "\\") {
|
||||
return "", fmt.Errorf("invalid-path: backslash not allowed")
|
||||
}
|
||||
if looksAbsolute(input) {
|
||||
return "", fmt.Errorf("invalid-path: absolute path rejected")
|
||||
}
|
||||
|
||||
normalized := input
|
||||
for _, part := range strings.Split(normalized, "/") {
|
||||
if part == ".." {
|
||||
return "", fmt.Errorf("invalid-path: path-traversal")
|
||||
}
|
||||
}
|
||||
|
||||
cleaned := path.Clean(normalized)
|
||||
if cleaned == "." {
|
||||
cleaned = ""
|
||||
}
|
||||
if cleaned == "" && !allowRoot {
|
||||
return "", fmt.Errorf("invalid-path: empty path")
|
||||
}
|
||||
if cleaned == ".." || strings.HasPrefix(cleaned, "../") {
|
||||
return "", fmt.Errorf("invalid-path: path-traversal")
|
||||
}
|
||||
if IsReservedPathNoNormalize(cleaned) {
|
||||
return "", fmt.Errorf("reserved-path: .verstak is internal")
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
func IsReservedPathNoNormalize(cleaned string) bool {
|
||||
if cleaned == "" {
|
||||
return false
|
||||
}
|
||||
first := strings.Split(cleaned, "/")[0]
|
||||
return strings.EqualFold(first, ".verstak")
|
||||
}
|
||||
|
||||
func looksAbsolute(input string) bool {
|
||||
if input == "" {
|
||||
return false
|
||||
}
|
||||
if filepath.IsAbs(input) || strings.HasPrefix(input, "/") || strings.HasPrefix(input, "\\\\") || strings.HasPrefix(input, "\\") {
|
||||
return true
|
||||
}
|
||||
if len(input) >= 2 && input[1] == ':' && unicode.IsLetter(rune(input[0])) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeRelativeDirAllowsRootAndPreservesCase(t *testing.T) {
|
||||
got, err := NormalizeRelativeDir("")
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeRelativeDir root: %v", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("root dir = %q, want empty", got)
|
||||
}
|
||||
|
||||
got, err = NormalizeRelativeDir("Notes/Overview.md")
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeRelativeDir preserves case: %v", err)
|
||||
}
|
||||
if got != "Notes/Overview.md" {
|
||||
t.Fatalf("path = %q, want Notes/Overview.md", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRelativeFileRejectsUnsafePaths(t *testing.T) {
|
||||
cases := []string{
|
||||
"",
|
||||
"/etc/passwd",
|
||||
"C:\\Users\\file.txt",
|
||||
"C:/Windows/system.ini",
|
||||
`\\server\share`,
|
||||
"//server/share",
|
||||
`..\secret`,
|
||||
`folder\..\secret`,
|
||||
"../outside.txt",
|
||||
"folder/../../outside.txt",
|
||||
`folder\sub/../../secret`,
|
||||
`folder\sub`,
|
||||
"bad\x00name.txt",
|
||||
".verstak",
|
||||
".verstak/",
|
||||
".verstak/vault.json",
|
||||
"./.verstak",
|
||||
".verstak/trash",
|
||||
"folder/../.verstak",
|
||||
".Verstak",
|
||||
}
|
||||
|
||||
for _, input := range cases {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
_, err := NormalizeRelativeFile(input)
|
||||
if err == nil {
|
||||
t.Fatalf("NormalizeRelativeFile(%q): expected error", input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReservedPathPolicy(t *testing.T) {
|
||||
if !IsReservedPath(".verstak") {
|
||||
t.Fatal(".verstak should be reserved")
|
||||
}
|
||||
if !IsReservedPath(".verstak/trash/file.txt") {
|
||||
t.Fatal(".verstak/trash/file.txt should be reserved")
|
||||
}
|
||||
if !IsReservedPath(".Verstak/trash/file.txt") {
|
||||
t.Fatal(".Verstak/trash/file.txt should be reserved by case-insensitive policy")
|
||||
}
|
||||
if IsReservedPath("Notes/.verstak.md") {
|
||||
t.Fatal("Notes/.verstak.md should not be reserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRelativeFileAcceptsOnlySlashSeparatedRelativePaths(t *testing.T) {
|
||||
got, err := NormalizeRelativeFile("Notes/Overview.md")
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeRelativeFile valid slash path: %v", err)
|
||||
}
|
||||
if got != "Notes/Overview.md" {
|
||||
t.Fatalf("path = %q, want Notes/Overview.md", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathPolicyErrorsAreReadable(t *testing.T) {
|
||||
_, err := NormalizeRelativeFile("../outside.txt")
|
||||
if err == nil {
|
||||
t.Fatal("expected traversal error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "path-traversal") {
|
||||
t.Fatalf("error = %q, want path-traversal", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/verstak/verstak-desktop/internal/core/vault"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
vault *vault.Vault
|
||||
}
|
||||
|
||||
func NewService(v *vault.Vault) *Service {
|
||||
return &Service{vault: v}
|
||||
}
|
||||
|
||||
func (s *Service) ListVaultFiles(relativeDir string) ([]FileEntry, error) {
|
||||
root, err := s.vaultRoot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rel, err := NormalizeRelativeDir(relativeDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
full, err := s.resolve(root, rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rejectSymlinkPath(root, rel, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := os.Stat(full)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("not-found: %s", rel)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("not-directory: %s", rel)
|
||||
}
|
||||
|
||||
dirEntries, err := os.ReadDir(full)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := make([]FileEntry, 0, len(dirEntries))
|
||||
for _, dirEntry := range dirEntries {
|
||||
childRel := joinRel(rel, dirEntry.Name())
|
||||
if IsReservedPathNoNormalize(childRel) {
|
||||
continue
|
||||
}
|
||||
info, err := dirEntry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, makeEntry(childRel, info))
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetVaultFileMetadata(relativePath string) (FileMetadata, error) {
|
||||
root, rel, full, err := s.resolveFile(relativePath)
|
||||
if err != nil {
|
||||
return FileMetadata{}, err
|
||||
}
|
||||
if err := rejectSymlinkPath(root, rel, false); err != nil {
|
||||
return FileMetadata{}, err
|
||||
}
|
||||
info, err := os.Lstat(full)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return FileMetadata{}, fmt.Errorf("not-found: %s", rel)
|
||||
}
|
||||
return FileMetadata{}, err
|
||||
}
|
||||
return makeMetadata(rel, info), nil
|
||||
}
|
||||
|
||||
func (s *Service) ReadVaultTextFile(relativePath string) (string, error) {
|
||||
root, rel, full, err := s.resolveFile(relativePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := rejectSymlinkPath(root, rel, true); err != nil {
|
||||
return "", err
|
||||
}
|
||||
info, err := os.Lstat(full)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("not-found: %s", rel)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return "", fmt.Errorf("symlink-not-allowed: %s", rel)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", fmt.Errorf("not-regular-file: %s", rel)
|
||||
}
|
||||
if info.Size() > MaxTextFileBytes {
|
||||
return "", fmt.Errorf("file-too-large: %s", rel)
|
||||
}
|
||||
data, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !utf8.Valid(data) {
|
||||
return "", fmt.Errorf("not-text-file: %s", rel)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func (s *Service) WriteVaultTextFile(relativePath string, content string, options WriteOptions) error {
|
||||
root, rel, full, err := s.resolveFile(relativePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectSymlinkPath(root, rel, true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
parent := filepath.Dir(full)
|
||||
if info, err := os.Stat(parent); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("parent-not-found: %s", pathDir(rel))
|
||||
}
|
||||
return err
|
||||
} else if !info.IsDir() {
|
||||
return fmt.Errorf("parent-not-directory: %s", pathDir(rel))
|
||||
}
|
||||
|
||||
existing, err := os.Lstat(full)
|
||||
if err == nil {
|
||||
if existing.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("symlink-not-allowed: %s", rel)
|
||||
}
|
||||
if !existing.Mode().IsRegular() {
|
||||
return fmt.Errorf("not-regular-file: %s", rel)
|
||||
}
|
||||
if !options.Overwrite {
|
||||
return fmt.Errorf("conflict: %s", rel)
|
||||
}
|
||||
} else if os.IsNotExist(err) {
|
||||
if !options.CreateIfMissing {
|
||||
return fmt.Errorf("not-found: %s", rel)
|
||||
}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp(parent, ".verstak-write-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
cleanup := true
|
||||
defer func() {
|
||||
if cleanup {
|
||||
_ = os.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
if _, err := tmp.WriteString(content); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpName, full); err != nil {
|
||||
return err
|
||||
}
|
||||
cleanup = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateVaultFolder(relativePath string) error {
|
||||
root, rel, full, err := s.resolveFile(relativePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Lstat(full); err == nil {
|
||||
return fmt.Errorf("conflict: %s", rel)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err := rejectSymlinkPath(root, rel, true); err != nil {
|
||||
return err
|
||||
}
|
||||
parent := filepath.Dir(full)
|
||||
if info, err := os.Stat(parent); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("parent-not-found: %s", pathDir(rel))
|
||||
}
|
||||
return err
|
||||
} else if !info.IsDir() {
|
||||
return fmt.Errorf("parent-not-directory: %s", pathDir(rel))
|
||||
}
|
||||
return os.Mkdir(full, 0o755)
|
||||
}
|
||||
|
||||
func (s *Service) MoveVaultPath(fromRelativePath string, toRelativePath string, options MoveOptions) error {
|
||||
root, fromRel, fromFull, err := s.resolveFile(fromRelativePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, toRel, toFull, err := s.resolveFile(toRelativePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fromRel == "" || toRel == "" {
|
||||
return fmt.Errorf("invalid-path: cannot move root")
|
||||
}
|
||||
if err := rejectSymlinkPath(root, fromRel, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectSymlinkPath(root, toRel, false); err != nil {
|
||||
return err
|
||||
}
|
||||
fromInfo, err := os.Lstat(fromFull)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("not-found: %s", fromRel)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if fromInfo.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("symlink-not-allowed: %s", fromRel)
|
||||
}
|
||||
if fromInfo.IsDir() && (toRel == fromRel || strings.HasPrefix(toRel, fromRel+"/")) {
|
||||
return fmt.Errorf("move-into-self: %s -> %s", fromRel, toRel)
|
||||
}
|
||||
parent := filepath.Dir(toFull)
|
||||
if info, err := os.Stat(parent); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("parent-not-found: %s", pathDir(toRel))
|
||||
}
|
||||
return err
|
||||
} else if !info.IsDir() {
|
||||
return fmt.Errorf("parent-not-directory: %s", pathDir(toRel))
|
||||
}
|
||||
if _, err := os.Lstat(toFull); err == nil && !options.Overwrite {
|
||||
return fmt.Errorf("conflict: %s", toRel)
|
||||
} else if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return os.Rename(fromFull, toFull)
|
||||
}
|
||||
|
||||
func (s *Service) TrashVaultPath(relativePath string) (TrashResult, error) {
|
||||
root, rel, full, err := s.resolveFile(relativePath)
|
||||
if err != nil {
|
||||
return TrashResult{}, err
|
||||
}
|
||||
if err := rejectSymlinkPath(root, rel, true); err != nil {
|
||||
return TrashResult{}, err
|
||||
}
|
||||
info, err := os.Lstat(full)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return TrashResult{}, fmt.Errorf("not-found: %s", rel)
|
||||
}
|
||||
return TrashResult{}, err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return TrashResult{}, fmt.Errorf("symlink-not-allowed: %s", rel)
|
||||
}
|
||||
|
||||
deletedAt := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
trashID := time.Now().UTC().Format("20060102T150405.000000000Z") + "-" + uuid.NewString()
|
||||
trashRel := filepath.ToSlash(filepath.Join(".verstak", "trash", "files", trashID, filepath.Base(rel)))
|
||||
trashFull := filepath.Join(root, filepath.FromSlash(trashRel))
|
||||
if err := os.MkdirAll(filepath.Dir(trashFull), 0o755); err != nil {
|
||||
return TrashResult{}, err
|
||||
}
|
||||
if err := os.Rename(full, trashFull); err != nil {
|
||||
return TrashResult{}, err
|
||||
}
|
||||
result := TrashResult{
|
||||
OriginalPath: rel,
|
||||
TrashPath: trashRel,
|
||||
TrashID: trashID,
|
||||
DeletedAt: deletedAt,
|
||||
}
|
||||
meta := map[string]string{
|
||||
"originalPath": rel,
|
||||
"trashPath": trashRel,
|
||||
"trashId": trashID,
|
||||
"deletedAt": deletedAt,
|
||||
"originalType": string(fileTypeFromInfo(info)),
|
||||
"basename": filepath.Base(rel),
|
||||
"type": string(fileTypeFromInfo(info)),
|
||||
}
|
||||
data, err := json.MarshalIndent(meta, "", " ")
|
||||
if err != nil {
|
||||
return TrashResult{}, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, ".verstak", "trash", "files", trashID, "metadata.json"), data, 0o644); err != nil {
|
||||
return TrashResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) vaultRoot() (string, error) {
|
||||
if s == nil || s.vault == nil {
|
||||
return "", fmt.Errorf("vault-not-initialized")
|
||||
}
|
||||
if s.vault.GetVaultStatus() != vault.StatusOpen {
|
||||
return "", fmt.Errorf("vault-not-open")
|
||||
}
|
||||
root := s.vault.GetVaultPath()
|
||||
if root == "" {
|
||||
return "", fmt.Errorf("vault-not-open")
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
func (s *Service) resolveFile(relativePath string) (string, string, string, error) {
|
||||
root, err := s.vaultRoot()
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
rel, err := NormalizeRelativeFile(relativePath)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
full, err := s.resolve(root, rel)
|
||||
return root, rel, full, err
|
||||
}
|
||||
|
||||
func (s *Service) resolve(root, rel string) (string, error) {
|
||||
full := filepath.Join(root, filepath.FromSlash(rel))
|
||||
absRoot, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
absFull, err := filepath.Abs(full)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
relToRoot, err := filepath.Rel(absRoot, absFull)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if relToRoot == ".." || strings.HasPrefix(relToRoot, ".."+string(os.PathSeparator)) || filepath.IsAbs(relToRoot) {
|
||||
return "", fmt.Errorf("invalid-path: path-traversal")
|
||||
}
|
||||
return absFull, nil
|
||||
}
|
||||
|
||||
func rejectSymlinkPath(root, rel string, includeFinal bool) error {
|
||||
if rel == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(rel, "/")
|
||||
limit := len(parts)
|
||||
if !includeFinal {
|
||||
limit--
|
||||
}
|
||||
current := root
|
||||
for i := 0; i < limit; i++ {
|
||||
current = filepath.Join(current, filepath.FromSlash(parts[i]))
|
||||
info, err := os.Lstat(current)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("symlink-not-allowed: %s", strings.Join(parts[:i+1], "/"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func makeEntry(rel string, info fs.FileInfo) FileEntry {
|
||||
t := fileTypeFromInfo(info)
|
||||
return FileEntry{
|
||||
Name: info.Name(),
|
||||
RelativePath: rel,
|
||||
Type: t,
|
||||
Size: sizeForType(t, info),
|
||||
ModifiedAt: info.ModTime().UTC().Format(time.RFC3339Nano),
|
||||
Extension: strings.TrimPrefix(filepath.Ext(info.Name()), "."),
|
||||
IsHidden: strings.HasPrefix(info.Name(), "."),
|
||||
IsReserved: IsReservedPathNoNormalize(rel),
|
||||
CanRead: t == FileTypeFile || t == FileTypeFolder,
|
||||
CanWrite: t == FileTypeFile || t == FileTypeFolder,
|
||||
}
|
||||
}
|
||||
|
||||
func makeMetadata(rel string, info fs.FileInfo) FileMetadata {
|
||||
t := fileTypeFromInfo(info)
|
||||
ext := strings.TrimPrefix(filepath.Ext(info.Name()), ".")
|
||||
return FileMetadata{
|
||||
RelativePath: rel,
|
||||
Type: t,
|
||||
Size: sizeForType(t, info),
|
||||
ModifiedAt: info.ModTime().UTC().Format(time.RFC3339Nano),
|
||||
Extension: ext,
|
||||
MimeHint: mime.TypeByExtension(filepath.Ext(info.Name())),
|
||||
IsText: isTextExtension(ext),
|
||||
IsHidden: strings.HasPrefix(info.Name(), "."),
|
||||
IsReserved: IsReservedPathNoNormalize(rel),
|
||||
CanRead: t == FileTypeFile || t == FileTypeFolder,
|
||||
CanWrite: t == FileTypeFile || t == FileTypeFolder,
|
||||
}
|
||||
}
|
||||
|
||||
func fileTypeFromInfo(info fs.FileInfo) FileType {
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return FileTypeSymlink
|
||||
}
|
||||
if info.IsDir() {
|
||||
return FileTypeFolder
|
||||
}
|
||||
if info.Mode().IsRegular() {
|
||||
return FileTypeFile
|
||||
}
|
||||
return FileTypeUnknown
|
||||
}
|
||||
|
||||
func sizeForType(t FileType, info fs.FileInfo) int64 {
|
||||
if t == FileTypeFolder {
|
||||
return 0
|
||||
}
|
||||
return info.Size()
|
||||
}
|
||||
|
||||
func isTextExtension(ext string) bool {
|
||||
switch strings.ToLower(ext) {
|
||||
case "txt", "md", "markdown", "json", "yaml", "yml", "toml", "csv", "log", "xml", "html", "css", "js", "ts", "svelte", "go":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func joinRel(parent, name string) string {
|
||||
if parent == "" {
|
||||
return name
|
||||
}
|
||||
return parent + "/" + name
|
||||
}
|
||||
|
||||
func pathDir(rel string) string {
|
||||
dir := pathDirSlash(rel)
|
||||
if dir == "." {
|
||||
return ""
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func pathDirSlash(rel string) string {
|
||||
idx := strings.LastIndex(rel, "/")
|
||||
if idx < 0 {
|
||||
return "."
|
||||
}
|
||||
return rel[:idx]
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/verstak/verstak-desktop/internal/core/vault"
|
||||
)
|
||||
|
||||
func newTestService(t *testing.T) (*Service, string) {
|
||||
t.Helper()
|
||||
v := vault.NewVault(nil)
|
||||
if err := v.CreateVault(t.TempDir()); err != nil {
|
||||
t.Fatalf("CreateVault: %v", err)
|
||||
}
|
||||
return NewService(v), v.GetVaultPath()
|
||||
}
|
||||
|
||||
func TestServiceRequiresOpenVault(t *testing.T) {
|
||||
v := vault.NewVault(nil)
|
||||
s := NewService(v)
|
||||
|
||||
if _, err := s.ListVaultFiles(""); err == nil {
|
||||
t.Fatal("ListVaultFiles with closed vault: expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListVaultFilesExcludesReservedAndReturnsEntries(t *testing.T) {
|
||||
s, root := newTestService(t)
|
||||
if err := os.WriteFile(filepath.Join(root, "readme.md"), []byte("hello"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(root, "Docs"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entries, err := s.ListVaultFiles("")
|
||||
if err != nil {
|
||||
t.Fatalf("ListVaultFiles: %v", err)
|
||||
}
|
||||
|
||||
names := map[string]FileEntry{}
|
||||
for _, entry := range entries {
|
||||
names[entry.Name] = entry
|
||||
if strings.HasPrefix(entry.RelativePath, ".verstak") {
|
||||
t.Fatalf("reserved entry leaked into list: %+v", entry)
|
||||
}
|
||||
}
|
||||
if names["readme.md"].Type != FileTypeFile {
|
||||
t.Fatalf("readme.md type = %q", names["readme.md"].Type)
|
||||
}
|
||||
if names["Docs"].Type != FileTypeFolder {
|
||||
t.Fatalf("Docs type = %q", names["Docs"].Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathPolicyRejectsUnsafeOperations(t *testing.T) {
|
||||
s, _ := newTestService(t)
|
||||
|
||||
cases := []string{
|
||||
"/etc/passwd",
|
||||
"C:\\Windows\\system.ini",
|
||||
"C:/Windows/system.ini",
|
||||
`\\server\share`,
|
||||
"//server/share",
|
||||
`..\outside`,
|
||||
`folder\..\outside`,
|
||||
"../outside",
|
||||
"folder/../../outside",
|
||||
`folder\sub/../../outside`,
|
||||
"bad\x00path",
|
||||
".verstak",
|
||||
".verstak/",
|
||||
".verstak/vault.json",
|
||||
"./.verstak",
|
||||
".Verstak/trash",
|
||||
}
|
||||
for _, input := range cases {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
if _, err := s.GetVaultFileMetadata(input); err == nil {
|
||||
t.Fatal("metadata: expected error")
|
||||
}
|
||||
if _, err := s.ReadVaultTextFile(input); err == nil {
|
||||
t.Fatal("read: expected error")
|
||||
}
|
||||
if err := s.WriteVaultTextFile(input, "x", WriteOptions{CreateIfMissing: true}); err == nil {
|
||||
t.Fatal("write: expected error")
|
||||
}
|
||||
if err := s.MoveVaultPath(input, "safe.txt", MoveOptions{}); err == nil {
|
||||
t.Fatal("move: expected error")
|
||||
}
|
||||
if _, err := s.TrashVaultPath(input); err == nil {
|
||||
t.Fatal("trash: expected error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadVaultTextFileRules(t *testing.T) {
|
||||
s, root := newTestService(t)
|
||||
if err := os.WriteFile(filepath.Join(root, "note.md"), []byte("hello\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(root, "Folder"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "binary.bin"), []byte{0xff, 0xfe, 0xfd}, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "huge.txt"), []byte(strings.Repeat("a", int(MaxTextFileBytes)+1)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
text, err := s.ReadVaultTextFile("note.md")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadVaultTextFile note: %v", err)
|
||||
}
|
||||
if text != "hello\n" {
|
||||
t.Fatalf("text = %q", text)
|
||||
}
|
||||
|
||||
if _, err := s.ReadVaultTextFile("Folder"); err == nil || !strings.Contains(err.Error(), "not-regular-file") {
|
||||
t.Fatalf("read folder error = %v, want not-regular-file", err)
|
||||
}
|
||||
if _, err := s.ReadVaultTextFile("missing.md"); err == nil || !strings.Contains(err.Error(), "not-found") {
|
||||
t.Fatalf("read missing error = %v, want not-found", err)
|
||||
}
|
||||
if _, err := s.ReadVaultTextFile("huge.txt"); err == nil || !strings.Contains(err.Error(), "file-too-large") {
|
||||
t.Fatalf("read huge error = %v, want file-too-large", err)
|
||||
}
|
||||
if _, err := s.ReadVaultTextFile("binary.bin"); err == nil || !strings.Contains(err.Error(), "not-text-file") {
|
||||
t.Fatalf("read binary error = %v, want not-text-file", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteVaultTextFileAtomicAndConflictBehavior(t *testing.T) {
|
||||
s, root := newTestService(t)
|
||||
|
||||
if err := s.WriteVaultTextFile("Notes/one.md", "first", WriteOptions{CreateIfMissing: true}); err == nil {
|
||||
t.Fatal("write should fail when parent folder is missing")
|
||||
}
|
||||
if err := s.CreateVaultFolder("Notes"); err != nil {
|
||||
t.Fatalf("CreateVaultFolder: %v", err)
|
||||
}
|
||||
if err := s.WriteVaultTextFile("Notes/one.md", "first", WriteOptions{CreateIfMissing: true}); err != nil {
|
||||
t.Fatalf("write create: %v", err)
|
||||
}
|
||||
if err := s.WriteVaultTextFile("Notes/one.md", "second", WriteOptions{CreateIfMissing: true}); err == nil || !strings.Contains(err.Error(), "conflict") {
|
||||
t.Fatalf("write conflict error = %v, want conflict", err)
|
||||
}
|
||||
if err := s.WriteVaultTextFile("Notes/one.md", "second", WriteOptions{CreateIfMissing: true, Overwrite: true}); err != nil {
|
||||
t.Fatalf("write overwrite: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(root, "Notes", "one.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "second" {
|
||||
t.Fatalf("file content = %q", string(data))
|
||||
}
|
||||
|
||||
matches, err := filepath.Glob(filepath.Join(root, "Notes", ".verstak-write-*"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(matches) != 0 {
|
||||
t.Fatalf("atomic write left temp files: %v", matches)
|
||||
}
|
||||
|
||||
if err := s.WriteVaultTextFile("", "root", WriteOptions{CreateIfMissing: true}); err == nil || !strings.Contains(err.Error(), "empty path") {
|
||||
t.Fatalf("write root error = %v, want empty path", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateVaultFolderConflict(t *testing.T) {
|
||||
s, _ := newTestService(t)
|
||||
if err := s.CreateVaultFolder("Folder"); err != nil {
|
||||
t.Fatalf("CreateVaultFolder first: %v", err)
|
||||
}
|
||||
if err := s.CreateVaultFolder("Folder"); err == nil || !strings.Contains(err.Error(), "conflict") {
|
||||
t.Fatalf("CreateVaultFolder conflict error = %v, want conflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveVaultPathRules(t *testing.T) {
|
||||
s, root := newTestService(t)
|
||||
if err := os.Mkdir(filepath.Join(root, "A"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "A", "one.txt"), []byte("one"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "target.txt"), []byte("target"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := s.MoveVaultPath("A/one.txt", "moved.txt", MoveOptions{}); err != nil {
|
||||
t.Fatalf("move file: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "moved.txt")); err != nil {
|
||||
t.Fatalf("moved file missing: %v", err)
|
||||
}
|
||||
|
||||
if err := s.MoveVaultPath("A", "B", MoveOptions{}); err != nil {
|
||||
t.Fatalf("move folder: %v", err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(root, "C"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.MoveVaultPath("C", "C/Child", MoveOptions{}); err == nil || !strings.Contains(err.Error(), "move-into-self") {
|
||||
t.Fatalf("move into self error = %v, want move-into-self", err)
|
||||
}
|
||||
if err := s.MoveVaultPath("moved.txt", "target.txt", MoveOptions{}); err == nil || !strings.Contains(err.Error(), "conflict") {
|
||||
t.Fatalf("move conflict error = %v, want conflict", err)
|
||||
}
|
||||
if err := s.MoveVaultPath("", "root-move", MoveOptions{}); err == nil {
|
||||
t.Fatal("move root should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashVaultPathMovesToReservedTrashAndHidesFromList(t *testing.T) {
|
||||
s, root := newTestService(t)
|
||||
if err := os.WriteFile(filepath.Join(root, "delete-me.txt"), []byte("bye"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(root, "delete-folder"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "same.txt"), []byte("one"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(root, "Other"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "Other", "same.txt"), []byte("two"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fileResult, err := s.TrashVaultPath("delete-me.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("trash file: %v", err)
|
||||
}
|
||||
if fileResult.OriginalPath != "delete-me.txt" || fileResult.TrashID == "" || fileResult.DeletedAt == "" {
|
||||
t.Fatalf("unexpected trash result: %+v", fileResult)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, fileResult.TrashPath)); err != nil {
|
||||
t.Fatalf("trashed file missing: %v", err)
|
||||
}
|
||||
metaPath := filepath.Join(root, ".verstak", "trash", "files", fileResult.TrashID, "metadata.json")
|
||||
metaData, err := os.ReadFile(metaPath)
|
||||
if err != nil {
|
||||
t.Fatalf("trash metadata missing: %v", err)
|
||||
}
|
||||
var meta map[string]string
|
||||
if err := json.Unmarshal(metaData, &meta); err != nil {
|
||||
t.Fatalf("trash metadata invalid JSON: %v", err)
|
||||
}
|
||||
for _, key := range []string{"originalPath", "deletedAt", "originalType", "trashId", "basename"} {
|
||||
if meta[key] == "" {
|
||||
t.Fatalf("trash metadata missing %s: %s", key, string(metaData))
|
||||
}
|
||||
}
|
||||
if meta["basename"] != "delete-me.txt" || meta["originalType"] != string(FileTypeFile) {
|
||||
t.Fatalf("trash metadata = %+v", meta)
|
||||
}
|
||||
|
||||
if _, err := s.TrashVaultPath("delete-folder"); err != nil {
|
||||
t.Fatalf("trash folder: %v", err)
|
||||
}
|
||||
firstSame, err := s.TrashVaultPath("same.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("trash same root: %v", err)
|
||||
}
|
||||
secondSame, err := s.TrashVaultPath("Other/same.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("trash same nested: %v", err)
|
||||
}
|
||||
if firstSame.TrashID == secondSame.TrashID || firstSame.TrashPath == secondSame.TrashPath {
|
||||
t.Fatalf("repeated trash basename collided: first=%+v second=%+v", firstSame, secondSame)
|
||||
}
|
||||
if _, err := s.TrashVaultPath(""); err == nil {
|
||||
t.Fatal("trash root should fail")
|
||||
}
|
||||
if _, err := s.TrashVaultPath("missing.txt"); err == nil || !strings.Contains(err.Error(), "not-found") {
|
||||
t.Fatalf("trash missing error = %v, want not-found", err)
|
||||
}
|
||||
|
||||
entries, err := s.ListVaultFiles("")
|
||||
if err != nil {
|
||||
t.Fatalf("ListVaultFiles: %v", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.Name == "delete-me.txt" || entry.Name == "delete-folder" || entry.Name == ".verstak" {
|
||||
t.Fatalf("unexpected entry after trash: %+v", entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSymlinkEscapeRejected(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation requires privileges on many Windows test environments")
|
||||
}
|
||||
|
||||
s, root := newTestService(t)
|
||||
outside := t.TempDir()
|
||||
outsideFile := filepath.Join(outside, "outside.txt")
|
||||
if err := os.WriteFile(outsideFile, []byte("secret"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outsideFile, filepath.Join(root, "escape.txt")); err != nil {
|
||||
t.Skipf("symlink not supported in this environment: %v", err)
|
||||
}
|
||||
|
||||
meta, err := s.GetVaultFileMetadata("escape.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("metadata symlink: %v", err)
|
||||
}
|
||||
if meta.Type != FileTypeSymlink {
|
||||
t.Fatalf("symlink type = %q", meta.Type)
|
||||
}
|
||||
|
||||
if _, err := s.ReadVaultTextFile("escape.txt"); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
|
||||
t.Fatalf("read symlink error = %v, want symlink-not-allowed", err)
|
||||
}
|
||||
if err := s.WriteVaultTextFile("escape.txt", "x", WriteOptions{Overwrite: true}); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
|
||||
t.Fatalf("write symlink error = %v, want symlink-not-allowed", err)
|
||||
}
|
||||
if err := s.MoveVaultPath("escape.txt", "moved-link.txt", MoveOptions{}); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
|
||||
t.Fatalf("move symlink error = %v, want symlink-not-allowed", err)
|
||||
}
|
||||
if _, err := s.TrashVaultPath("escape.txt"); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
|
||||
t.Fatalf("trash symlink error = %v, want symlink-not-allowed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSymlinkInsideVaultRejectedForMutatingAndReadOperations(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation requires privileges on many Windows test environments")
|
||||
}
|
||||
|
||||
s, root := newTestService(t)
|
||||
if err := os.WriteFile(filepath.Join(root, "target.txt"), []byte("inside"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(root, "target.txt"), filepath.Join(root, "inside-link.txt")); err != nil {
|
||||
t.Skipf("symlink not supported in this environment: %v", err)
|
||||
}
|
||||
|
||||
meta, err := s.GetVaultFileMetadata("inside-link.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("metadata inside symlink: %v", err)
|
||||
}
|
||||
if meta.Type != FileTypeSymlink {
|
||||
t.Fatalf("symlink type = %q", meta.Type)
|
||||
}
|
||||
if _, err := s.ReadVaultTextFile("inside-link.txt"); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
|
||||
t.Fatalf("read inside symlink error = %v, want symlink-not-allowed", err)
|
||||
}
|
||||
if err := s.WriteVaultTextFile("inside-link.txt", "x", WriteOptions{Overwrite: true}); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
|
||||
t.Fatalf("write inside symlink error = %v, want symlink-not-allowed", err)
|
||||
}
|
||||
if err := s.MoveVaultPath("inside-link.txt", "moved-link.txt", MoveOptions{}); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
|
||||
t.Fatalf("move inside symlink error = %v, want symlink-not-allowed", err)
|
||||
}
|
||||
if _, err := s.TrashVaultPath("inside-link.txt"); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
|
||||
t.Fatalf("trash inside symlink error = %v, want symlink-not-allowed", err)
|
||||
}
|
||||
|
||||
matches, err := filepath.Glob(filepath.Join(root, ".verstak-write-*"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(matches) != 0 {
|
||||
t.Fatalf("write symlink left root temp files: %v", matches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListVaultFilesRejectsSymlinkDirectoryEscape(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation requires privileges on many Windows test environments")
|
||||
}
|
||||
|
||||
s, root := newTestService(t)
|
||||
outside := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(outside, "outside.txt"), []byte("secret"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(root, "outside-dir")); err != nil {
|
||||
t.Skipf("symlink not supported in this environment: %v", err)
|
||||
}
|
||||
|
||||
if _, err := s.ListVaultFiles("outside-dir"); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
|
||||
t.Fatalf("list symlink dir error = %v, want symlink-not-allowed", err)
|
||||
}
|
||||
|
||||
entries, err := s.ListVaultFiles("")
|
||||
if err != nil {
|
||||
t.Fatalf("list root: %v", err)
|
||||
}
|
||||
var foundSymlink bool
|
||||
for _, entry := range entries {
|
||||
if entry.RelativePath == "outside-dir" {
|
||||
foundSymlink = true
|
||||
if entry.Type != FileTypeSymlink {
|
||||
t.Fatalf("root symlink entry type = %q, want symlink", entry.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundSymlink {
|
||||
t.Fatal("root list should expose the symlink as metadata without following it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateVaultFolderRejectsSymlinkParentEscape(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation requires privileges on many Windows test environments")
|
||||
}
|
||||
|
||||
s, root := newTestService(t)
|
||||
outside := t.TempDir()
|
||||
if err := os.Symlink(outside, filepath.Join(root, "outside-dir")); err != nil {
|
||||
t.Skipf("symlink not supported in this environment: %v", err)
|
||||
}
|
||||
|
||||
if err := s.CreateVaultFolder("outside-dir/new-folder"); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
|
||||
t.Fatalf("create folder through symlink parent error = %v, want symlink-not-allowed", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "new-folder")); !os.IsNotExist(err) {
|
||||
t.Fatalf("folder should not be created outside vault, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package files
|
||||
|
||||
const MaxTextFileBytes int64 = 2 * 1024 * 1024
|
||||
|
||||
type FileType string
|
||||
|
||||
const (
|
||||
FileTypeFile FileType = "file"
|
||||
FileTypeFolder FileType = "folder"
|
||||
FileTypeSymlink FileType = "symlink"
|
||||
FileTypeUnknown FileType = "unknown"
|
||||
)
|
||||
|
||||
type FileEntry struct {
|
||||
Name string `json:"name"`
|
||||
RelativePath string `json:"relativePath"`
|
||||
Type FileType `json:"type"`
|
||||
Size int64 `json:"size"`
|
||||
ModifiedAt string `json:"modifiedAt"`
|
||||
Extension string `json:"extension"`
|
||||
IsHidden bool `json:"isHidden"`
|
||||
IsReserved bool `json:"isReserved"`
|
||||
CanRead bool `json:"canRead"`
|
||||
CanWrite bool `json:"canWrite"`
|
||||
}
|
||||
|
||||
type FileMetadata struct {
|
||||
RelativePath string `json:"relativePath"`
|
||||
Type FileType `json:"type"`
|
||||
Size int64 `json:"size"`
|
||||
ModifiedAt string `json:"modifiedAt"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
Extension string `json:"extension"`
|
||||
MimeHint string `json:"mimeHint"`
|
||||
IsText bool `json:"isText"`
|
||||
IsHidden bool `json:"isHidden"`
|
||||
IsReserved bool `json:"isReserved"`
|
||||
CanRead bool `json:"canRead"`
|
||||
CanWrite bool `json:"canWrite"`
|
||||
}
|
||||
|
||||
type WriteOptions struct {
|
||||
CreateIfMissing bool `json:"createIfMissing"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
}
|
||||
|
||||
type MoveOptions struct {
|
||||
Overwrite bool `json:"overwrite"`
|
||||
}
|
||||
|
||||
type TrashResult struct {
|
||||
OriginalPath string `json:"originalPath"`
|
||||
TrashPath string `json:"trashPath"`
|
||||
TrashID string `json:"trashId"`
|
||||
DeletedAt string `json:"deletedAt"`
|
||||
}
|
||||
Reference in New Issue
Block a user