437 lines
15 KiB
Go
437 lines
15 KiB
Go
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)
|
|
}
|
|
}
|