fix: второй стабилизационный pass vault layout — sync payload, bindings, vaultPath, tests
sync_apply.go: - applyRemoteNodeCreate: полный payload (template_id/fs_path/sort_order/archived), INSERT сохраняет все поля, для folder-like создаётся физическая папка. - applyRemoteNodeUpdate: принимает fs_path/template_id/archived, физическое переименование папки при изменении title/fs_path. - applyRemoteNodeMove: принимает fs_path, обновляет parent_id+fs_path, физически перемещает папку (folder-like) или file record (note/file). bindings_nodes.go: - MoveNode: node.FsPath = newFsPath после UpdateFsPath; sync.RecordOp отправляет новый fs_path; note/file move to root — файл в vault root. - RenameNode: EntityFile для file, EntityNote для note; коллизия → генерация уникального имени; файл переименовывается только после os.Rename. - DeleteNode: единый вызов a.files.DeleteNodeAndChildren(), дублирование удалено. - Исправлен deadlock с SetMaxOpenConns(1) — Query/Exec больше не конфликтуют. files.Service.vaultPath: filepath.Rel-based проверка, sibling-prefix escape (/tmp/vault vs /tmp/vault_evil) отклоняется. VaultCheck: SQL JOIN с n.deleted_at IS NULL, чтобы удалённые узлы не показывались как missing files. Добавлены тесты: RenameFileNodeUsesEntityFile, MoveNoteToRoot, DeleteFolderLeavesVaultCheckHealthy, SyncNodeCreatePreservesFields, VaultPathSiblingPrefixEscape.
This commit is contained in:
@@ -102,12 +102,24 @@ func validateName(name string) error {
|
||||
|
||||
// vaultPath resolves a relative vault path and checks it stays within jail.
|
||||
func (s *Service) vaultPath(rel string) (string, error) {
|
||||
abs := filepath.Join(s.vaultRoot, rel)
|
||||
cleaned := filepath.Clean(abs)
|
||||
if !strings.HasPrefix(cleaned, filepath.Clean(s.vaultRoot)) {
|
||||
return "", fmt.Errorf("path escapes vault root")
|
||||
if rel == "" {
|
||||
return "", fmt.Errorf("empty path")
|
||||
}
|
||||
return cleaned, nil
|
||||
if filepath.IsAbs(rel) {
|
||||
return "", fmt.Errorf("absolute path not allowed: %s", rel)
|
||||
}
|
||||
cleaned := filepath.Clean(rel)
|
||||
joined := filepath.Join(s.vaultRoot, cleaned)
|
||||
joinedClean := filepath.Clean(joined)
|
||||
|
||||
relToVault, err := filepath.Rel(s.vaultRoot, joinedClean)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("path escapes vault root: %s", rel)
|
||||
}
|
||||
if relToVault == ".." || strings.HasPrefix(relToVault, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("path escapes vault root: %s", rel)
|
||||
}
|
||||
return joinedClean, nil
|
||||
}
|
||||
|
||||
// absPathSafe resolves an absolute path and checks jail if it's under vault.
|
||||
@@ -447,11 +459,13 @@ func (s *Service) DeleteNodeAndChildren(nodeID string) error {
|
||||
}
|
||||
}
|
||||
_ = s.deleteFileRecords(nodeID)
|
||||
// Move physical folder to trash if the node has fs_path
|
||||
n, err := s.nodes.GetActive(nodeID)
|
||||
if err == nil && n.FsPath != "" {
|
||||
src := filepath.Join(s.vaultRoot, n.FsPath)
|
||||
if info, err := os.Stat(src); err == nil && info.IsDir() {
|
||||
src, vaultErr := s.vaultPath(n.FsPath)
|
||||
if vaultErr != nil {
|
||||
src = filepath.Join(s.vaultRoot, n.FsPath)
|
||||
}
|
||||
if info, statErr := os.Stat(src); statErr == nil && info.IsDir() {
|
||||
trashDir := filepath.Join(s.vaultRoot, ".verstak", "trash")
|
||||
os.MkdirAll(trashDir, 0o750)
|
||||
trashPath := filepath.Join(trashDir, n.ID+"_"+templates.SafeDisplayNameToPathSegment(n.Title))
|
||||
|
||||
@@ -305,6 +305,42 @@ func TestPreviewImportDir(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultPathSiblingPrefixEscape(t *testing.T) {
|
||||
vaultRoot := "/tmp/vault"
|
||||
svc := &Service{vaultRoot: vaultRoot}
|
||||
|
||||
// Normal path should pass
|
||||
_, err := svc.vaultPath("some/file.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("expected OK, got: %v", err)
|
||||
}
|
||||
|
||||
// Sibling-prefix escape should fail
|
||||
// vault=/tmp/vault, path goes to /tmp/vault_evil -> should be rejected
|
||||
_, err = svc.vaultPath("../vault_evil/file.txt")
|
||||
if err == nil {
|
||||
t.Error("expected error for sibling-prefix escape, got nil")
|
||||
}
|
||||
|
||||
// Direct escape with ../..
|
||||
_, err = svc.vaultPath("../../etc/passwd")
|
||||
if err == nil {
|
||||
t.Error("expected error for path escape, got nil")
|
||||
}
|
||||
|
||||
// Absolute path
|
||||
_, err = svc.vaultPath("/etc/passwd")
|
||||
if err == nil {
|
||||
t.Error("expected error for absolute path, got nil")
|
||||
}
|
||||
|
||||
// Empty path
|
||||
_, err = svc.vaultPath("")
|
||||
if err == nil {
|
||||
t.Error("expected error for empty path, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuessMIME(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"a.md": "text/plain",
|
||||
|
||||
Reference in New Issue
Block a user