Harden core snapshot sync and workspace lifecycle
This commit is contained in:
@@ -470,25 +470,35 @@ func (m *Manager) CreateWorkspace(name, templateID string) (Workspace, error) {
|
||||
|
||||
func writeWorkspaceIdentity(workspacePath string) (string, error) {
|
||||
workspaceID := uuid.NewString()
|
||||
data, err := json.Marshal(workspaceIdentityMarker{WorkspaceID: workspaceID})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
markerPath := filepath.Join(workspacePath, filepath.FromSlash(workspaceIdentityRelativePath))
|
||||
if err := os.MkdirAll(filepath.Dir(markerPath), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmpPath := markerPath + ".tmp"
|
||||
if err := os.WriteFile(tmpPath, data, 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(tmpPath, markerPath); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
if err := writeWorkspaceIdentityValue(workspacePath, workspaceID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return workspaceID, nil
|
||||
}
|
||||
|
||||
func writeWorkspaceIdentityValue(workspacePath, workspaceID string) error {
|
||||
if _, err := uuid.Parse(workspaceID); err != nil {
|
||||
return fmt.Errorf("invalid workspace identity: %w", err)
|
||||
}
|
||||
data, err := json.Marshal(workspaceIdentityMarker{WorkspaceID: workspaceID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
markerPath := filepath.Join(workspacePath, filepath.FromSlash(workspaceIdentityRelativePath))
|
||||
if err := os.MkdirAll(filepath.Dir(markerPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := markerPath + ".tmp"
|
||||
if err := os.WriteFile(tmpPath, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpPath, markerPath); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureWorkspaceIdentity(workspacePath string) (string, error) {
|
||||
workspaceID, err := readWorkspaceIdentity(workspacePath)
|
||||
if os.IsNotExist(err) {
|
||||
@@ -739,6 +749,213 @@ func (m *Manager) RestoreWorkspaceTrash(trashID, targetName string) (Workspace,
|
||||
return Workspace{ID: workspaceID, Name: targetName, RootPath: targetName}, nil
|
||||
}
|
||||
|
||||
// CreateWorkspaceFromSync creates a workspace from the core-only sync contract.
|
||||
// It deliberately does not apply a live template: child folders and files are
|
||||
// represented by normal file operations, while the captured metadata preserves
|
||||
// the historical template snapshot that matters for restoration.
|
||||
func (m *Manager) CreateWorkspaceFromSync(name, workspaceID string, meta Metadata) (Workspace, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if err := validateWorkspaceName(name); err != nil {
|
||||
return Workspace{}, err
|
||||
}
|
||||
if _, err := uuid.Parse(workspaceID); err != nil {
|
||||
return Workspace{}, fmt.Errorf("invalid workspace identity: %w", err)
|
||||
}
|
||||
if existing, found, err := m.findActiveWorkspaceByID(workspaceID); err != nil {
|
||||
return Workspace{}, err
|
||||
} else if found {
|
||||
if existing.Name == name {
|
||||
return existing, nil
|
||||
}
|
||||
return Workspace{}, fmt.Errorf("conflict: workspace identity %s already belongs to %s", workspaceID, existing.Name)
|
||||
}
|
||||
|
||||
full := filepath.Join(m.vaultDir, name)
|
||||
if _, err := os.Lstat(full); err == nil {
|
||||
return Workspace{}, fmt.Errorf("conflict: %s", name)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return Workspace{}, err
|
||||
}
|
||||
if err := os.Mkdir(full, 0o755); err != nil {
|
||||
return Workspace{}, err
|
||||
}
|
||||
created := true
|
||||
defer func() {
|
||||
if created {
|
||||
_ = os.RemoveAll(full)
|
||||
}
|
||||
}()
|
||||
if err := writeWorkspaceIdentityValue(full, workspaceID); err != nil {
|
||||
return Workspace{}, err
|
||||
}
|
||||
meta.WorkspaceID = workspaceID
|
||||
meta.WorkspaceName = name
|
||||
if meta.Features == nil {
|
||||
meta.Features = map[string]bool{"files": true}
|
||||
}
|
||||
if meta.Folders == nil {
|
||||
meta.Folders = defaultFolders()
|
||||
}
|
||||
if meta.UpdatedAt == "" {
|
||||
meta.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
for _, folder := range meta.Folders {
|
||||
if strings.TrimSpace(folder) == "" {
|
||||
continue
|
||||
}
|
||||
if err := validateWorkspaceFolderPath(folder); err != nil {
|
||||
return Workspace{}, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(full, folder), 0o755); err != nil {
|
||||
return Workspace{}, err
|
||||
}
|
||||
}
|
||||
if err := m.writeMetadata(name, meta); err != nil {
|
||||
return Workspace{}, err
|
||||
}
|
||||
created = false
|
||||
return Workspace{ID: workspaceID, Name: name, RootPath: name}, nil
|
||||
}
|
||||
|
||||
func validateWorkspaceFolderPath(folder string) error {
|
||||
folder = strings.TrimSpace(folder)
|
||||
if folder == "" || filepath.IsAbs(folder) || strings.Contains(folder, `\`) {
|
||||
return fmt.Errorf("invalid workspace folder path")
|
||||
}
|
||||
cleaned := filepath.ToSlash(filepath.Clean(filepath.FromSlash(folder)))
|
||||
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") {
|
||||
return fmt.Errorf("invalid workspace folder path")
|
||||
}
|
||||
for _, segment := range strings.Split(cleaned, "/") {
|
||||
if strings.EqualFold(segment, ".verstak") {
|
||||
return fmt.Errorf("invalid workspace folder path")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenameWorkspaceFromSync applies an idempotent rename only to the durable
|
||||
// identity named by the operation. A different local path is a visible conflict.
|
||||
func (m *Manager) RenameWorkspaceFromSync(workspaceID, oldName, newName string) error {
|
||||
newName = strings.TrimSpace(newName)
|
||||
if err := validateWorkspaceName(newName); err != nil {
|
||||
return err
|
||||
}
|
||||
workspace, found, err := m.findActiveWorkspaceByID(workspaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("not-found: workspace identity %s", workspaceID)
|
||||
}
|
||||
if workspace.Name == newName {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(oldName) != "" && workspace.Name != oldName {
|
||||
return fmt.Errorf("conflict: workspace identity %s is at %s, expected %s", workspaceID, workspace.Name, oldName)
|
||||
}
|
||||
return m.RenameWorkspace(workspace.Name, newName)
|
||||
}
|
||||
|
||||
// TrashWorkspaceFromSync moves the identified active workspace into this
|
||||
// device's local trash. Local trash IDs are intentionally not synchronized.
|
||||
func (m *Manager) TrashWorkspaceFromSync(workspaceID, name string) (TrashResult, error) {
|
||||
workspace, found, err := m.findActiveWorkspaceByID(workspaceID)
|
||||
if err != nil {
|
||||
return TrashResult{}, err
|
||||
}
|
||||
if !found {
|
||||
if _, trashed, err := m.findTrashedWorkspaceByID(workspaceID); err != nil {
|
||||
return TrashResult{}, err
|
||||
} else if trashed {
|
||||
return TrashResult{WorkspaceID: workspaceID}, nil
|
||||
}
|
||||
return TrashResult{}, fmt.Errorf("not-found: workspace identity %s", workspaceID)
|
||||
}
|
||||
if strings.TrimSpace(name) != "" && workspace.Name != name {
|
||||
return TrashResult{}, fmt.Errorf("conflict: workspace identity %s is at %s, expected %s", workspaceID, workspace.Name, name)
|
||||
}
|
||||
return m.TrashWorkspace(workspace.Name)
|
||||
}
|
||||
|
||||
// RestoreWorkspaceFromSync restores a workspace by durable identity. It is
|
||||
// idempotent at the requested target and rejects a workspace of another ID.
|
||||
func (m *Manager) RestoreWorkspaceFromSync(workspaceID, targetName string) (Workspace, error) {
|
||||
targetName = strings.TrimSpace(targetName)
|
||||
if err := validateWorkspaceName(targetName); err != nil {
|
||||
return Workspace{}, err
|
||||
}
|
||||
if active, found, err := m.findActiveWorkspaceByID(workspaceID); err != nil {
|
||||
return Workspace{}, err
|
||||
} else if found {
|
||||
if active.Name == targetName {
|
||||
return active, nil
|
||||
}
|
||||
return Workspace{}, fmt.Errorf("conflict: workspace identity %s is already active at %s", workspaceID, active.Name)
|
||||
}
|
||||
trashID, found, err := m.findTrashedWorkspaceByID(workspaceID)
|
||||
if err != nil {
|
||||
return Workspace{}, err
|
||||
}
|
||||
if !found {
|
||||
return Workspace{}, fmt.Errorf("not-found: trashed workspace identity %s", workspaceID)
|
||||
}
|
||||
return m.RestoreWorkspaceTrash(trashID, targetName)
|
||||
}
|
||||
|
||||
func (m *Manager) findActiveWorkspaceByID(workspaceID string) (Workspace, bool, error) {
|
||||
if _, err := uuid.Parse(workspaceID); err != nil {
|
||||
return Workspace{}, false, fmt.Errorf("invalid workspace identity: %w", err)
|
||||
}
|
||||
workspaces, err := m.ListWorkspaces()
|
||||
if err != nil {
|
||||
return Workspace{}, false, err
|
||||
}
|
||||
var match Workspace
|
||||
for _, workspace := range workspaces {
|
||||
if workspace.ID != workspaceID {
|
||||
continue
|
||||
}
|
||||
if match.ID != "" {
|
||||
return Workspace{}, false, fmt.Errorf("conflict: duplicated workspace identity %s", workspaceID)
|
||||
}
|
||||
match = workspace
|
||||
}
|
||||
return match, match.ID != "", nil
|
||||
}
|
||||
|
||||
func (m *Manager) findTrashedWorkspaceByID(workspaceID string) (string, bool, error) {
|
||||
if _, err := uuid.Parse(workspaceID); err != nil {
|
||||
return "", false, fmt.Errorf("invalid workspace identity: %w", err)
|
||||
}
|
||||
trashRoot := filepath.Join(m.vaultDir, ".verstak", "trash", "workspaces")
|
||||
entries, err := os.ReadDir(trashRoot)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, err
|
||||
}
|
||||
var match string
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
identity, err := m.GetWorkspaceTrashIdentity(entry.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if identity.WorkspaceID != workspaceID {
|
||||
continue
|
||||
}
|
||||
if match != "" {
|
||||
return "", false, fmt.Errorf("conflict: duplicated trashed workspace identity %s", workspaceID)
|
||||
}
|
||||
match = entry.Name()
|
||||
}
|
||||
return match, match != "", nil
|
||||
}
|
||||
|
||||
func validateWorkspaceTrashID(trashID string) error {
|
||||
if trashID == "" || strings.ContainsAny(trashID, `/\\`) || filepath.Clean(trashID) != trashID {
|
||||
return fmt.Errorf("invalid workspace trash ID")
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestListWorkspacesReadsTopLevelPhysicalFolders(t *testing.T) {
|
||||
@@ -435,6 +437,93 @@ func TestRestoreWorkspaceTrashPreservesIdentity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySyncedWorkspaceLifecycleKeepsIdentityAndRejectsNameConflict(t *testing.T) {
|
||||
vaultDir := newVaultDir(t)
|
||||
m := NewManager(vaultDir)
|
||||
workspaceID := "5f0f96d9-61c8-4b6b-8c3a-a1b9a0f40001"
|
||||
meta := Metadata{
|
||||
WorkspaceID: workspaceID,
|
||||
WorkspaceName: "Remote",
|
||||
CreatedFromTemplate: &TemplateSnapshot{
|
||||
TemplateID: "minimal",
|
||||
TemplateName: "Minimal",
|
||||
TemplateVersion: 1,
|
||||
AppliedAt: "2026-07-17T00:00:00Z",
|
||||
},
|
||||
Features: map[string]bool{"files": true},
|
||||
Folders: map[string]string{"notes": "Notes"},
|
||||
}
|
||||
|
||||
created, err := m.CreateWorkspaceFromSync("Remote", workspaceID, meta)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorkspaceFromSync: %v", err)
|
||||
}
|
||||
if created.ID != workspaceID || created.Name != "Remote" {
|
||||
t.Fatalf("created = %+v", created)
|
||||
}
|
||||
if _, err := m.CreateWorkspaceFromSync("Remote", workspaceID, meta); err != nil {
|
||||
t.Fatalf("replayed create: %v", err)
|
||||
}
|
||||
|
||||
if err := m.RenameWorkspaceFromSync(workspaceID, "Remote", "Remote-Renamed"); err != nil {
|
||||
t.Fatalf("RenameWorkspaceFromSync: %v", err)
|
||||
}
|
||||
if err := m.RenameWorkspaceFromSync(workspaceID, "Remote", "Remote-Renamed"); err != nil {
|
||||
t.Fatalf("replayed rename: %v", err)
|
||||
}
|
||||
trashed, err := m.TrashWorkspaceFromSync(workspaceID, "Remote-Renamed")
|
||||
if err != nil {
|
||||
t.Fatalf("TrashWorkspaceFromSync: %v", err)
|
||||
}
|
||||
if trashed.WorkspaceID != workspaceID {
|
||||
t.Fatalf("trashed = %+v", trashed)
|
||||
}
|
||||
if _, err := m.TrashWorkspaceFromSync(workspaceID, "Remote-Renamed"); err != nil {
|
||||
t.Fatalf("replayed trash: %v", err)
|
||||
}
|
||||
restored, err := m.RestoreWorkspaceFromSync(workspaceID, "Remote-Restored")
|
||||
if err != nil {
|
||||
t.Fatalf("RestoreWorkspaceFromSync: %v", err)
|
||||
}
|
||||
if restored.ID != workspaceID || restored.Name != "Remote-Restored" {
|
||||
t.Fatalf("restored = %+v", restored)
|
||||
}
|
||||
if _, err := m.RestoreWorkspaceFromSync(workspaceID, "Remote-Restored"); err != nil {
|
||||
t.Fatalf("replayed restore: %v", err)
|
||||
}
|
||||
|
||||
if _, err := m.CreateWorkspace("Taken", "minimal"); err != nil {
|
||||
t.Fatalf("CreateWorkspace Taken: %v", err)
|
||||
}
|
||||
if err := m.RenameWorkspaceFromSync(workspaceID, "Remote-Restored", "Taken"); err == nil || !strings.Contains(err.Error(), "conflict") {
|
||||
t.Fatalf("rename conflict error = %v, want conflict", err)
|
||||
}
|
||||
if _, err := m.CreateWorkspaceFromSync("Copied", workspaceID, meta); err == nil || !strings.Contains(err.Error(), "conflict") {
|
||||
t.Fatalf("duplicate workspace ID error = %v, want conflict", err)
|
||||
}
|
||||
|
||||
stored, err := m.GetWorkspaceMetadata("Remote-Restored")
|
||||
if err != nil {
|
||||
t.Fatalf("GetWorkspaceMetadata: %v", err)
|
||||
}
|
||||
if stored.WorkspaceID != workspaceID || stored.CreatedFromTemplate == nil || stored.CreatedFromTemplate.TemplateID != "minimal" {
|
||||
t.Fatalf("stored metadata = %+v", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWorkspaceFromSyncRejectsUnsafeMetadataFolder(t *testing.T) {
|
||||
vaultDir := newVaultDir(t)
|
||||
m := NewManager(vaultDir)
|
||||
if _, err := m.CreateWorkspaceFromSync("Remote", uuid.NewString(), Metadata{
|
||||
Folders: map[string]string{"files": "../escaped"},
|
||||
}); err == nil || !strings.Contains(err.Error(), "invalid workspace folder") {
|
||||
t.Fatalf("unsafe synced metadata error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(vaultDir, "escaped")); !os.IsNotExist(err) {
|
||||
t.Fatalf("unsafe synced metadata created path outside workspace: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurgeWorkspaceTrashRemovesPayload(t *testing.T) {
|
||||
vaultDir := newVaultDir(t)
|
||||
m := NewManager(vaultDir)
|
||||
|
||||
Reference in New Issue
Block a user