Harden core snapshot sync and workspace lifecycle
This commit is contained in:
+189
-18
@@ -5,25 +5,30 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
EntityNode = "node"
|
||||
EntityNote = "note"
|
||||
EntityFile = "file"
|
||||
EntityFolder = "folder"
|
||||
EntityAction = "action"
|
||||
EntityWorklog = "worklog"
|
||||
EntityNode = "node"
|
||||
EntityNote = "note"
|
||||
EntityFile = "file"
|
||||
EntityFolder = "folder"
|
||||
EntityWorkspace = "workspace"
|
||||
EntityAction = "action"
|
||||
EntityWorklog = "worklog"
|
||||
)
|
||||
|
||||
const (
|
||||
OpCreate = "create"
|
||||
OpUpdate = "update"
|
||||
OpDelete = "delete"
|
||||
OpMove = "move"
|
||||
OpCreate = "create"
|
||||
OpUpdate = "update"
|
||||
OpDelete = "delete"
|
||||
OpMove = "move"
|
||||
OpRename = "rename"
|
||||
OpTrash = "trash"
|
||||
OpRestore = "restore"
|
||||
)
|
||||
|
||||
// Op represents a sync operation.
|
||||
@@ -45,11 +50,14 @@ type Op struct {
|
||||
|
||||
// syncState persists connection state to JSON file.
|
||||
type syncState struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
DeviceID string `json:"device_id"`
|
||||
LastPullSeq int `json:"last_pull_seq"`
|
||||
LastSyncAt string `json:"last_sync_at"`
|
||||
ServerURL string `json:"server_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
DeviceID string `json:"device_id"`
|
||||
LastPullSeq int `json:"last_pull_seq"`
|
||||
LastSyncAt string `json:"last_sync_at"`
|
||||
BootstrapComplete bool `json:"bootstrap_complete"`
|
||||
LastWarning string `json:"last_warning"`
|
||||
RemoteVaultID string `json:"remote_vault_id"`
|
||||
}
|
||||
|
||||
// Service records and manages sync operations using JSON file storage.
|
||||
@@ -81,6 +89,14 @@ func (s *Service) statePath() string {
|
||||
return filepath.Join(s.syncDir(), "state.json")
|
||||
}
|
||||
|
||||
func (s *Service) snapshotPath() string {
|
||||
return filepath.Join(s.syncDir(), "snapshot.json")
|
||||
}
|
||||
|
||||
func (s *Service) scanJournalPath() string {
|
||||
return filepath.Join(s.syncDir(), "scan-journal.json")
|
||||
}
|
||||
|
||||
func (s *Service) ensureDir() error {
|
||||
return os.MkdirAll(s.syncDir(), 0o755)
|
||||
}
|
||||
@@ -113,11 +129,33 @@ func (s *Service) RecordOp(entityType, entityID, opType string, payload interfac
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
return s.recordOps([]Op{op})
|
||||
}
|
||||
|
||||
// recordOps is idempotent by op ID so a scanner recovery journal can safely
|
||||
// resume after a crash between recording operations and replacing its snapshot.
|
||||
func (s *Service) recordOps(newOps []Op) error {
|
||||
if err := s.ensureDir(); err != nil {
|
||||
return err
|
||||
}
|
||||
ops, err := s.loadOps()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ops = append(ops, op)
|
||||
existing := make(map[string]bool, len(ops))
|
||||
for _, op := range ops {
|
||||
existing[op.OpID] = true
|
||||
}
|
||||
for _, op := range newOps {
|
||||
if op.OpID == "" || existing[op.OpID] {
|
||||
continue
|
||||
}
|
||||
if op.ID == "" {
|
||||
op.ID = op.OpID
|
||||
}
|
||||
ops = append(ops, op)
|
||||
existing[op.OpID] = true
|
||||
}
|
||||
return s.saveOps(ops)
|
||||
}
|
||||
|
||||
@@ -160,6 +198,42 @@ func (s *Service) GetUnpushedOps() ([]Op, error) {
|
||||
return unpushed, nil
|
||||
}
|
||||
|
||||
// HasUnpushedPath reports whether a local operation still owns a path (or one
|
||||
// of its descendants). Pull uses it to turn an incoming overwrite/delete into
|
||||
// a visible conflict instead of silently replacing a local external edit.
|
||||
func (s *Service) HasUnpushedPath(path string) (bool, error) {
|
||||
ops, err := s.GetUnpushedOps()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, op := range ops {
|
||||
if syncPathsOverlap(path, op.EntityID) {
|
||||
return true, nil
|
||||
}
|
||||
var payload struct {
|
||||
Path string `json:"path"`
|
||||
FromPath string `json:"fromPath"`
|
||||
ToPath string `json:"toPath"`
|
||||
}
|
||||
if op.PayloadJSON == "" || json.Unmarshal([]byte(op.PayloadJSON), &payload) != nil {
|
||||
continue
|
||||
}
|
||||
if syncPathsOverlap(path, payload.Path) || syncPathsOverlap(path, payload.FromPath) || syncPathsOverlap(path, payload.ToPath) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func syncPathsOverlap(left, right string) bool {
|
||||
left = strings.Trim(left, "/")
|
||||
right = strings.Trim(right, "/")
|
||||
if left == "" || right == "" {
|
||||
return false
|
||||
}
|
||||
return left == right || strings.HasPrefix(left, right+"/") || strings.HasPrefix(right, left+"/")
|
||||
}
|
||||
|
||||
// MarkPushed marks ops as pushed to server.
|
||||
func (s *Service) MarkPushed(opIDs []string) error {
|
||||
ops, err := s.loadOps()
|
||||
@@ -244,6 +318,66 @@ func (s *Service) SetLastSyncAt(t string) error {
|
||||
return s.saveState(st)
|
||||
}
|
||||
|
||||
// BootstrapComplete reports whether the initial pull/reconcile/bootstrap cycle
|
||||
// finished successfully for this vault connection.
|
||||
func (s *Service) BootstrapComplete() (bool, error) {
|
||||
st, err := s.loadState()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return st.BootstrapComplete, nil
|
||||
}
|
||||
|
||||
// SetBootstrapComplete marks the initial reconciliation as complete only after
|
||||
// all remote operations were applied and the local initial snapshot was queued.
|
||||
func (s *Service) SetBootstrapComplete(done bool) error {
|
||||
st, err := s.loadState()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st.BootstrapComplete = done
|
||||
return s.saveState(st)
|
||||
}
|
||||
|
||||
// LastWarning returns the persistent scanner warning shown by sync status.
|
||||
func (s *Service) LastWarning() (string, error) {
|
||||
st, err := s.loadState()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return st.LastWarning, nil
|
||||
}
|
||||
|
||||
// SetLastWarning persists an unresolved scanner condition. An empty string
|
||||
// clears the warning once a later complete scan no longer reports it.
|
||||
func (s *Service) SetLastWarning(message string) error {
|
||||
st, err := s.loadState()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st.LastWarning = message
|
||||
return s.saveState(st)
|
||||
}
|
||||
|
||||
// RemoteVaultID returns the optional target vault chosen while pairing a new
|
||||
// local vault for restore. Empty means this vault's own durable ID was used.
|
||||
func (s *Service) RemoteVaultID() (string, error) {
|
||||
st, err := s.loadState()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return st.RemoteVaultID, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetRemoteVaultID(vaultID string) error {
|
||||
st, err := s.loadState()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st.RemoteVaultID = vaultID
|
||||
return s.saveState(st)
|
||||
}
|
||||
|
||||
// GetDeviceID returns the device ID used by this service.
|
||||
func (s *Service) GetDeviceID() string {
|
||||
return s.deviceID
|
||||
@@ -288,7 +422,7 @@ func (s *Service) saveOps(ops []Op) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal ops: %w", err)
|
||||
}
|
||||
return os.WriteFile(s.opsPath(), data, 0o644)
|
||||
return atomicWriteFile(s.opsPath(), data, 0o600)
|
||||
}
|
||||
|
||||
func (s *Service) loadState() (*syncState, error) {
|
||||
@@ -311,5 +445,42 @@ func (s *Service) saveState(st *syncState) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal state: %w", err)
|
||||
}
|
||||
return os.WriteFile(s.statePath(), data, 0o644)
|
||||
return atomicWriteFile(s.statePath(), data, 0o600)
|
||||
}
|
||||
|
||||
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".verstak-sync-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
cleanup := true
|
||||
defer func() {
|
||||
if cleanup {
|
||||
_ = os.Remove(tmpPath)
|
||||
}
|
||||
}()
|
||||
if err := tmp.Chmod(perm); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
return err
|
||||
}
|
||||
cleanup = false
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,789 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
corefiles "github.com/verstak/verstak-desktop/internal/core/files"
|
||||
)
|
||||
|
||||
const snapshotVersion = 1
|
||||
const maxOperationFileBytes = corefiles.MaxBinaryReadBytes
|
||||
|
||||
// Snapshot is the durable local view of the synchronizable part of a vault.
|
||||
// Its entries are only files and folders whose latest state was successfully
|
||||
// represented by an operation or intentionally accepted as an initial baseline.
|
||||
type Snapshot struct {
|
||||
Version int `json:"version"`
|
||||
Entries map[string]SnapshotEntry `json:"entries"`
|
||||
Workspaces map[string]WorkspaceSnapshot `json:"workspaces,omitempty"`
|
||||
TrashedWorkspaces map[string]WorkspaceSnapshot `json:"trashedWorkspaces,omitempty"`
|
||||
WorkspacesInitialized bool `json:"workspacesInitialized,omitempty"`
|
||||
Unresolved map[string]string `json:"unresolved,omitempty"`
|
||||
}
|
||||
|
||||
// SnapshotEntry stores only stable filesystem facts needed for reconciliation.
|
||||
type SnapshotEntry struct {
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"size"`
|
||||
ModifiedAt string `json:"modifiedAt"`
|
||||
Hash string `json:"hash,omitempty"`
|
||||
}
|
||||
|
||||
// WorkspaceSnapshot keeps the core-owned identity and creation metadata of a
|
||||
// top-level workspace. The marker itself remains excluded from normal file
|
||||
// sync and is never exposed through the Files API.
|
||||
type WorkspaceSnapshot struct {
|
||||
WorkspaceID string `json:"workspaceId"`
|
||||
Path string `json:"path"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
Entries map[string]SnapshotEntry `json:"entries,omitempty"`
|
||||
}
|
||||
|
||||
type scanJournal struct {
|
||||
Snapshot Snapshot `json:"snapshot"`
|
||||
Ops []Op `json:"ops"`
|
||||
}
|
||||
|
||||
type scannedVault struct {
|
||||
Entries map[string]SnapshotEntry
|
||||
Workspaces map[string]WorkspaceSnapshot
|
||||
Unresolved map[string]string
|
||||
}
|
||||
|
||||
// LoadSnapshot returns the current durable scanner snapshot. A missing
|
||||
// snapshot is represented by an empty snapshot, which is useful to callers
|
||||
// that only need to inspect it.
|
||||
func (s *Service) LoadSnapshot() (Snapshot, error) {
|
||||
snapshot, exists, err := s.loadSnapshot()
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
if !exists {
|
||||
return newSnapshot(), nil
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
// ScanAndRecord scans the whole synchronizable vault and records exactly the
|
||||
// detected local changes. On its first run it writes a baseline and deliberately
|
||||
// produces no operations; bootstrap decides what can safely be published.
|
||||
func (s *Service) ScanAndRecord() ([]string, error) {
|
||||
if err := s.recoverScanJournal(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
previous, exists, err := s.loadSnapshot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current, warnings, err := scanVault(s.vaultRoot, previous)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next := snapshotFromScan(current, previous, exists)
|
||||
if !exists {
|
||||
if err := s.saveSnapshot(next); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return warnings, nil
|
||||
}
|
||||
|
||||
ops, next, err := diffSnapshots(previous, next, s.deviceID, s.vaultRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ops) == 0 {
|
||||
if err := s.saveSnapshot(next); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return warnings, nil
|
||||
}
|
||||
if err := s.commitScanTransaction(next, ops); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return warnings, nil
|
||||
}
|
||||
|
||||
// RecordBootstrapOps records creates for a pre-pull local snapshot. It is used
|
||||
// only after a successful initial pull, so an empty new vault never turns into
|
||||
// remote delete operations. Callers may pass the snapshot captured before the
|
||||
// pull; remote-only entries added during reconciliation are therefore not
|
||||
// reflected back to the server as local creates.
|
||||
func (s *Service) RecordBootstrapOps(initial Snapshot) error {
|
||||
if err := s.recoverScanJournal(); err != nil {
|
||||
return err
|
||||
}
|
||||
current, exists, err := s.loadSnapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("bootstrap requires an initial snapshot")
|
||||
}
|
||||
ops, _, err := diffSnapshots(newSnapshot(), initial, s.deviceID, s.vaultRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existing, err := s.GetUnpushedOps()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existingCreates := make(map[string]bool, len(existing))
|
||||
for _, op := range existing {
|
||||
if op.OpType == OpCreate {
|
||||
existingCreates[op.EntityType+"\x00"+op.EntityID] = true
|
||||
}
|
||||
}
|
||||
filtered := ops[:0]
|
||||
for _, op := range ops {
|
||||
if op.OpType == OpCreate && existingCreates[op.EntityType+"\x00"+op.EntityID] {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, op)
|
||||
}
|
||||
ops = filtered
|
||||
if len(ops) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.commitScanTransaction(current, ops)
|
||||
}
|
||||
|
||||
// RebaseSnapshot accepts filesystem changes that were applied from a remote
|
||||
// operation without producing any outgoing operation for them.
|
||||
func (s *Service) RebaseSnapshot() ([]string, error) {
|
||||
if err := s.recoverScanJournal(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
previous, exists, err := s.loadSnapshot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current, warnings, err := scanVault(s.vaultRoot, previous)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next := snapshotFromScan(current, previous, exists)
|
||||
acceptWorkspaceLifecycle(previous, &next)
|
||||
if err := s.saveSnapshot(next); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return warnings, nil
|
||||
}
|
||||
|
||||
func newSnapshot() Snapshot {
|
||||
return Snapshot{
|
||||
Version: snapshotVersion,
|
||||
Entries: make(map[string]SnapshotEntry),
|
||||
Workspaces: make(map[string]WorkspaceSnapshot),
|
||||
TrashedWorkspaces: make(map[string]WorkspaceSnapshot),
|
||||
WorkspacesInitialized: true,
|
||||
Unresolved: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) loadSnapshot() (Snapshot, bool, error) {
|
||||
data, err := os.ReadFile(s.snapshotPath())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return Snapshot{}, false, nil
|
||||
}
|
||||
return Snapshot{}, false, fmt.Errorf("read snapshot: %w", err)
|
||||
}
|
||||
var snapshot Snapshot
|
||||
if err := json.Unmarshal(data, &snapshot); err != nil {
|
||||
return Snapshot{}, false, fmt.Errorf("parse snapshot: %w", err)
|
||||
}
|
||||
if snapshot.Version != snapshotVersion {
|
||||
return Snapshot{}, false, fmt.Errorf("unsupported snapshot version: %d", snapshot.Version)
|
||||
}
|
||||
if snapshot.Entries == nil {
|
||||
snapshot.Entries = make(map[string]SnapshotEntry)
|
||||
}
|
||||
if snapshot.Workspaces == nil {
|
||||
snapshot.Workspaces = make(map[string]WorkspaceSnapshot)
|
||||
}
|
||||
if snapshot.TrashedWorkspaces == nil {
|
||||
snapshot.TrashedWorkspaces = make(map[string]WorkspaceSnapshot)
|
||||
}
|
||||
if snapshot.Unresolved == nil {
|
||||
snapshot.Unresolved = make(map[string]string)
|
||||
}
|
||||
return snapshot, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) saveSnapshot(snapshot Snapshot) error {
|
||||
data, err := json.MarshalIndent(snapshot, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal snapshot: %w", err)
|
||||
}
|
||||
return atomicWriteFile(s.snapshotPath(), data, 0o600)
|
||||
}
|
||||
|
||||
func (s *Service) loadScanJournal() (scanJournal, bool, error) {
|
||||
data, err := os.ReadFile(s.scanJournalPath())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return scanJournal{}, false, nil
|
||||
}
|
||||
return scanJournal{}, false, fmt.Errorf("read scan journal: %w", err)
|
||||
}
|
||||
var journal scanJournal
|
||||
if err := json.Unmarshal(data, &journal); err != nil {
|
||||
return scanJournal{}, false, fmt.Errorf("parse scan journal: %w", err)
|
||||
}
|
||||
return journal, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) saveScanJournal(journal scanJournal) error {
|
||||
data, err := json.MarshalIndent(journal, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal scan journal: %w", err)
|
||||
}
|
||||
return atomicWriteFile(s.scanJournalPath(), data, 0o600)
|
||||
}
|
||||
|
||||
func (s *Service) recoverScanJournal() error {
|
||||
journal, exists, err := s.loadScanJournal()
|
||||
if err != nil || !exists {
|
||||
return err
|
||||
}
|
||||
if err := s.recordOps(journal.Ops); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.saveSnapshot(journal.Snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(s.scanJournalPath()); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) commitScanTransaction(snapshot Snapshot, ops []Op) error {
|
||||
journal := scanJournal{Snapshot: snapshot, Ops: ops}
|
||||
if err := s.saveScanJournal(journal); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.recordOps(ops); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.saveSnapshot(snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(s.scanJournalPath()); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanVault(root string, previous Snapshot) (scannedVault, []string, error) {
|
||||
result := scannedVault{
|
||||
Entries: make(map[string]SnapshotEntry),
|
||||
Workspaces: make(map[string]WorkspaceSnapshot),
|
||||
Unresolved: make(map[string]string),
|
||||
}
|
||||
var warnings []string
|
||||
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if path == root {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if excludedFromSync(rel) {
|
||||
if entry.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
if entry.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
result.Entries[rel] = SnapshotEntry{
|
||||
Path: rel,
|
||||
Type: EntityFolder,
|
||||
ModifiedAt: info.ModTime().UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
if info.Size() > maxOperationFileBytes {
|
||||
message := fmt.Sprintf("file-too-large: %s (%d bytes exceeds %d bytes)", rel, info.Size(), maxOperationFileBytes)
|
||||
result.Unresolved[rel] = message
|
||||
warnings = append(warnings, message)
|
||||
return nil
|
||||
}
|
||||
hash, err := sha256File(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash %s: %w", rel, err)
|
||||
}
|
||||
result.Entries[rel] = SnapshotEntry{
|
||||
Path: rel,
|
||||
Type: EntityFile,
|
||||
Size: info.Size(),
|
||||
ModifiedAt: info.ModTime().UTC().Format(time.RFC3339Nano),
|
||||
Hash: hash,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return scannedVault{}, nil, err
|
||||
}
|
||||
workspaces, workspaceWarnings, err := scanWorkspaceSnapshots(root, previous.Workspaces)
|
||||
if err != nil {
|
||||
return scannedVault{}, nil, err
|
||||
}
|
||||
for workspaceID, workspace := range workspaces {
|
||||
result.Workspaces[workspaceID] = workspace
|
||||
}
|
||||
for _, warning := range workspaceWarnings {
|
||||
warnings = append(warnings, warning)
|
||||
if strings.HasPrefix(warning, "duplicate-workspace-id: ") {
|
||||
path := strings.TrimPrefix(warning, "duplicate-workspace-id: ")
|
||||
result.Unresolved[path] = warning
|
||||
removeEntriesUnder(result.Entries, path)
|
||||
}
|
||||
}
|
||||
sort.Strings(warnings)
|
||||
return result, warnings, nil
|
||||
}
|
||||
|
||||
func scanWorkspaceSnapshots(root string, preferred map[string]WorkspaceSnapshot) (map[string]WorkspaceSnapshot, []string, error) {
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
candidates := make(map[string][]WorkspaceSnapshot)
|
||||
var warnings []string
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() || entry.Type()&os.ModeSymlink != 0 || strings.EqualFold(entry.Name(), ".verstak") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(root, entry.Name(), ".verstak", "workspace.json")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return nil, nil, fmt.Errorf("read workspace marker %s: %w", entry.Name(), err)
|
||||
}
|
||||
var marker struct {
|
||||
WorkspaceID string `json:"workspaceId"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &marker); err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("invalid-workspace-id: %s", entry.Name()))
|
||||
continue
|
||||
}
|
||||
if _, err := uuid.Parse(marker.WorkspaceID); err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("invalid-workspace-id: %s", entry.Name()))
|
||||
continue
|
||||
}
|
||||
metadata, err := readWorkspaceMetadataSnapshot(root, entry.Name())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
candidates[marker.WorkspaceID] = append(candidates[marker.WorkspaceID], WorkspaceSnapshot{
|
||||
WorkspaceID: marker.WorkspaceID,
|
||||
Path: entry.Name(),
|
||||
Metadata: metadata,
|
||||
})
|
||||
}
|
||||
workspaces := make(map[string]WorkspaceSnapshot, len(candidates))
|
||||
for workspaceID, choices := range candidates {
|
||||
sort.Slice(choices, func(i, j int) bool { return choices[i].Path < choices[j].Path })
|
||||
selected := choices[0]
|
||||
if old, ok := preferred[workspaceID]; ok {
|
||||
for _, choice := range choices {
|
||||
if choice.Path == old.Path {
|
||||
selected = choice
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
workspaces[workspaceID] = selected
|
||||
for _, choice := range choices {
|
||||
if choice.Path != selected.Path {
|
||||
warnings = append(warnings, "duplicate-workspace-id: "+choice.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return workspaces, warnings, nil
|
||||
}
|
||||
|
||||
func readWorkspaceMetadataSnapshot(root, name string) (json.RawMessage, error) {
|
||||
path := filepath.Join(root, ".verstak", "workspaces", name, "metadata.json")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read workspace metadata %s: %w", name, err)
|
||||
}
|
||||
if !json.Valid(data) {
|
||||
return nil, fmt.Errorf("invalid workspace metadata: %s", name)
|
||||
}
|
||||
return json.RawMessage(append([]byte(nil), data...)), nil
|
||||
}
|
||||
|
||||
func excludedFromSync(rel string) bool {
|
||||
rel = filepath.ToSlash(rel)
|
||||
for _, segment := range strings.Split(rel, "/") {
|
||||
if strings.EqualFold(segment, ".verstak") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
base := filepath.Base(rel)
|
||||
return strings.HasPrefix(base, ".verstak-write-") || strings.HasSuffix(base, ".tmp") || strings.HasSuffix(base, ".swp") || strings.HasSuffix(base, "~")
|
||||
}
|
||||
|
||||
func sha256File(path string) (string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(hash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func snapshotFromScan(current scannedVault, previous Snapshot, previousExists bool) Snapshot {
|
||||
next := newSnapshot()
|
||||
for path, entry := range current.Entries {
|
||||
next.Entries[path] = entry
|
||||
}
|
||||
for workspaceID, workspace := range current.Workspaces {
|
||||
next.Workspaces[workspaceID] = workspace
|
||||
}
|
||||
for workspaceID, workspace := range previous.TrashedWorkspaces {
|
||||
next.TrashedWorkspaces[workspaceID] = workspace
|
||||
}
|
||||
for path, message := range current.Unresolved {
|
||||
next.Unresolved[path] = message
|
||||
copyEntriesUnder(next.Entries, previous.Entries, path)
|
||||
}
|
||||
if !previousExists {
|
||||
return next
|
||||
}
|
||||
for path, message := range previous.Unresolved {
|
||||
if _, supported := current.Entries[path]; supported {
|
||||
continue
|
||||
}
|
||||
if _, stillUnsupported := current.Unresolved[path]; stillUnsupported {
|
||||
continue
|
||||
}
|
||||
next.Unresolved[path] = "unresolved sync file disappeared before it could be synchronized: " + message
|
||||
copyEntriesUnder(next.Entries, previous.Entries, path)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func diffSnapshots(previous, next Snapshot, deviceID, vaultRoot string) ([]Op, Snapshot, error) {
|
||||
workspaceOps, err := diffWorkspaceSnapshots(&previous, &next, deviceID)
|
||||
if err != nil {
|
||||
return nil, next, err
|
||||
}
|
||||
var createsOrUpdates []Op
|
||||
var deletes []Op
|
||||
for path, entry := range next.Entries {
|
||||
old, existed := previous.Entries[path]
|
||||
if existed && entriesEqual(old, entry) {
|
||||
continue
|
||||
}
|
||||
if unresolvedPath(next.Unresolved, path) {
|
||||
continue
|
||||
}
|
||||
opType := OpCreate
|
||||
if existed {
|
||||
opType = OpUpdate
|
||||
}
|
||||
payload, err := payloadForEntry(vaultRoot, entry)
|
||||
if err != nil {
|
||||
return nil, next, err
|
||||
}
|
||||
createsOrUpdates = append(createsOrUpdates, newSnapshotOp(deviceID, entry.Type, path, opType, payload))
|
||||
}
|
||||
for path, old := range previous.Entries {
|
||||
if _, exists := next.Entries[path]; exists {
|
||||
continue
|
||||
}
|
||||
if unresolvedPath(previous.Unresolved, path) {
|
||||
continue
|
||||
}
|
||||
deletes = append(deletes, newSnapshotOp(deviceID, old.Type, path, OpDelete, map[string]string{"path": path}))
|
||||
}
|
||||
sort.Slice(createsOrUpdates, func(i, j int) bool {
|
||||
left, right := createsOrUpdates[i], createsOrUpdates[j]
|
||||
leftDepth, rightDepth := pathDepth(left.EntityID), pathDepth(right.EntityID)
|
||||
if leftDepth != rightDepth {
|
||||
return leftDepth < rightDepth
|
||||
}
|
||||
if left.EntityType != right.EntityType {
|
||||
return left.EntityType == EntityFolder
|
||||
}
|
||||
return left.EntityID < right.EntityID
|
||||
})
|
||||
sort.Slice(deletes, func(i, j int) bool {
|
||||
left, right := deletes[i], deletes[j]
|
||||
leftDepth, rightDepth := pathDepth(left.EntityID), pathDepth(right.EntityID)
|
||||
if leftDepth != rightDepth {
|
||||
return leftDepth > rightDepth
|
||||
}
|
||||
if left.EntityType != right.EntityType {
|
||||
return left.EntityType == EntityFile
|
||||
}
|
||||
return left.EntityID < right.EntityID
|
||||
})
|
||||
return append(workspaceOps, append(createsOrUpdates, deletes...)...), next, nil
|
||||
}
|
||||
|
||||
func diffWorkspaceSnapshots(previous, next *Snapshot, deviceID string) ([]Op, error) {
|
||||
var ops []Op
|
||||
if !previous.WorkspacesInitialized {
|
||||
return ops, nil
|
||||
}
|
||||
for workspaceID, oldWorkspace := range previous.Workspaces {
|
||||
currentWorkspace, active := next.Workspaces[workspaceID]
|
||||
if active {
|
||||
if currentWorkspace.Path != oldWorkspace.Path {
|
||||
ops = append(ops, newWorkspaceSnapshotOp(deviceID, workspaceID, OpRename, currentWorkspace, oldWorkspace.Path))
|
||||
remapEntriesPrefix(previous.Entries, oldWorkspace.Path, currentWorkspace.Path)
|
||||
}
|
||||
continue
|
||||
}
|
||||
ops = append(ops, newWorkspaceSnapshotOp(deviceID, workspaceID, OpTrash, oldWorkspace, ""))
|
||||
oldWorkspace.Entries = entriesUnder(previous.Entries, oldWorkspace.Path)
|
||||
removeEntriesUnder(previous.Entries, oldWorkspace.Path)
|
||||
next.TrashedWorkspaces[workspaceID] = oldWorkspace
|
||||
}
|
||||
for workspaceID, currentWorkspace := range next.Workspaces {
|
||||
if _, alreadyActive := previous.Workspaces[workspaceID]; alreadyActive {
|
||||
continue
|
||||
}
|
||||
if trashedWorkspace, wasTrashed := previous.TrashedWorkspaces[workspaceID]; wasTrashed {
|
||||
ops = append(ops, newWorkspaceSnapshotOp(deviceID, workspaceID, OpRestore, currentWorkspace, ""))
|
||||
copyRemappedEntries(previous.Entries, trashedWorkspace.Entries, trashedWorkspace.Path, currentWorkspace.Path)
|
||||
delete(next.TrashedWorkspaces, workspaceID)
|
||||
continue
|
||||
}
|
||||
ops = append(ops, newWorkspaceSnapshotOp(deviceID, workspaceID, OpCreate, currentWorkspace, ""))
|
||||
delete(next.Entries, currentWorkspace.Path)
|
||||
}
|
||||
sort.Slice(ops, func(i, j int) bool {
|
||||
if ops[i].OpType != ops[j].OpType {
|
||||
return workspaceOpOrder(ops[i].OpType) < workspaceOpOrder(ops[j].OpType)
|
||||
}
|
||||
return ops[i].EntityID < ops[j].EntityID
|
||||
})
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
func workspaceOpOrder(opType string) int {
|
||||
switch opType {
|
||||
case OpCreate:
|
||||
return 0
|
||||
case OpRename:
|
||||
return 1
|
||||
case OpRestore:
|
||||
return 2
|
||||
case OpTrash:
|
||||
return 3
|
||||
default:
|
||||
return 4
|
||||
}
|
||||
}
|
||||
|
||||
type snapshotWorkspacePayload struct {
|
||||
WorkspaceID string `json:"workspaceId"`
|
||||
Path string `json:"path"`
|
||||
PreviousPath string `json:"previousPath,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
func newWorkspaceSnapshotOp(deviceID, workspaceID, opType string, workspace WorkspaceSnapshot, previousPath string) Op {
|
||||
payload, _ := json.Marshal(snapshotWorkspacePayload{
|
||||
WorkspaceID: workspaceID,
|
||||
Path: workspace.Path,
|
||||
PreviousPath: previousPath,
|
||||
Name: workspace.Path,
|
||||
Metadata: workspace.Metadata,
|
||||
})
|
||||
return newSnapshotOp(deviceID, EntityWorkspace, workspaceID, opType, json.RawMessage(payload))
|
||||
}
|
||||
|
||||
func acceptWorkspaceLifecycle(previous Snapshot, next *Snapshot) {
|
||||
for workspaceID, oldWorkspace := range previous.Workspaces {
|
||||
if _, stillActive := next.Workspaces[workspaceID]; !stillActive {
|
||||
oldWorkspace.Entries = entriesUnder(previous.Entries, oldWorkspace.Path)
|
||||
next.TrashedWorkspaces[workspaceID] = oldWorkspace
|
||||
}
|
||||
}
|
||||
for workspaceID := range next.Workspaces {
|
||||
delete(next.TrashedWorkspaces, workspaceID)
|
||||
}
|
||||
}
|
||||
|
||||
func unresolvedPath(unresolved map[string]string, path string) bool {
|
||||
for unresolvedPath := range unresolved {
|
||||
if path == unresolvedPath || strings.HasPrefix(path, unresolvedPath+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func copyEntriesUnder(destination, source map[string]SnapshotEntry, root string) {
|
||||
for path, entry := range source {
|
||||
if path == root || strings.HasPrefix(path, root+"/") {
|
||||
destination[path] = entry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeEntriesUnder(entries map[string]SnapshotEntry, root string) {
|
||||
for path := range entries {
|
||||
if path == root || strings.HasPrefix(path, root+"/") {
|
||||
delete(entries, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func remapEntriesPrefix(entries map[string]SnapshotEntry, oldPrefix, newPrefix string) {
|
||||
type remappedEntry struct {
|
||||
oldPath string
|
||||
entry SnapshotEntry
|
||||
}
|
||||
var remapped []remappedEntry
|
||||
for path, entry := range entries {
|
||||
if path != oldPrefix && !strings.HasPrefix(path, oldPrefix+"/") {
|
||||
continue
|
||||
}
|
||||
suffix := strings.TrimPrefix(path, oldPrefix)
|
||||
entry.Path = newPrefix + suffix
|
||||
remapped = append(remapped, remappedEntry{oldPath: path, entry: entry})
|
||||
}
|
||||
for _, item := range remapped {
|
||||
delete(entries, item.oldPath)
|
||||
entries[item.entry.Path] = item.entry
|
||||
}
|
||||
}
|
||||
|
||||
func entriesUnder(entries map[string]SnapshotEntry, root string) map[string]SnapshotEntry {
|
||||
result := make(map[string]SnapshotEntry)
|
||||
copyEntriesUnder(result, entries, root)
|
||||
return result
|
||||
}
|
||||
|
||||
func copyRemappedEntries(destination, source map[string]SnapshotEntry, oldPrefix, newPrefix string) {
|
||||
for path, entry := range source {
|
||||
if path != oldPrefix && !strings.HasPrefix(path, oldPrefix+"/") {
|
||||
continue
|
||||
}
|
||||
suffix := strings.TrimPrefix(path, oldPrefix)
|
||||
entry.Path = newPrefix + suffix
|
||||
destination[entry.Path] = entry
|
||||
}
|
||||
}
|
||||
|
||||
func entriesEqual(left, right SnapshotEntry) bool {
|
||||
return left.Type == right.Type && left.Size == right.Size && left.Hash == right.Hash
|
||||
}
|
||||
|
||||
func payloadForEntry(vaultRoot string, entry SnapshotEntry) (map[string]string, error) {
|
||||
payload := map[string]string{"path": entry.Path, "contentHash": entry.Hash}
|
||||
if entry.Type == EntityFolder {
|
||||
return payload, nil
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(vaultRoot, filepath.FromSlash(entry.Path)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > maxOperationFileBytes {
|
||||
return nil, fmt.Errorf("file-too-large: %s", entry.Path)
|
||||
}
|
||||
if hash, err := sha256File(filepath.Join(vaultRoot, filepath.FromSlash(entry.Path))); err != nil {
|
||||
return nil, err
|
||||
} else if hash != entry.Hash {
|
||||
return nil, fmt.Errorf("file changed during scan: %s", entry.Path)
|
||||
}
|
||||
return filePayload(entry.Path, data, entry.Hash), nil
|
||||
}
|
||||
|
||||
func newSnapshotOp(deviceID, entityType, entityID, opType string, payload interface{}) Op {
|
||||
data, _ := json.Marshal(payload)
|
||||
id := uuid.NewString()
|
||||
return Op{
|
||||
ID: id,
|
||||
OpID: id,
|
||||
DeviceID: deviceID,
|
||||
EntityType: entityType,
|
||||
EntityID: entityID,
|
||||
OpType: opType,
|
||||
PayloadJSON: string(data),
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
}
|
||||
|
||||
func pathDepth(path string) int {
|
||||
if path == "" {
|
||||
return 0
|
||||
}
|
||||
return strings.Count(path, "/") + 1
|
||||
}
|
||||
|
||||
// filePayload encodes an already bounded file without changing its content
|
||||
// representation. UTF-8 files use plain text up to the existing text limit;
|
||||
// other supported files use the current bounded base64 transport.
|
||||
func filePayload(path string, data []byte, hash string) map[string]string {
|
||||
payload := map[string]string{"path": path, "contentHash": hash}
|
||||
if int64(len(data)) <= corefiles.MaxTextFileBytes && isSyncText(data) {
|
||||
payload["content"] = string(data)
|
||||
return payload
|
||||
}
|
||||
payload["dataBase64"] = base64.StdEncoding.EncodeToString(data)
|
||||
return payload
|
||||
}
|
||||
|
||||
func isSyncText(data []byte) bool {
|
||||
if !utf8.Valid(data) {
|
||||
return false
|
||||
}
|
||||
for _, r := range string(data) {
|
||||
if unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestScanAndRecordTracksExternalWorkspaceLifecycleByIdentity(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
workspaceID := uuid.NewString()
|
||||
createSnapshotWorkspace(t, root, "Project", workspaceID)
|
||||
if err := os.Mkdir(filepath.Join(root, "Project", "Files"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "Project", "Files", "note.txt"), []byte("one"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(root, "device-a")
|
||||
if _, err := service.ScanAndRecord(); err != nil {
|
||||
t.Fatalf("baseline: %v", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(filepath.Join(root, "Project"), filepath.Join(root, "Renamed")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.ScanAndRecord(); err != nil {
|
||||
t.Fatalf("scan rename: %v", err)
|
||||
}
|
||||
assertWorkspaceSnapshotOp(t, unpushedOps(t, service), 0, OpRename, workspaceID, "Renamed", "Project")
|
||||
if _, err := service.ScanAndRecord(); err != nil {
|
||||
t.Fatalf("unchanged renamed scan: %v", err)
|
||||
}
|
||||
if got := len(unpushedOps(t, service)); got != 1 {
|
||||
t.Fatalf("unchanged rename produced %d operations, want 1", got)
|
||||
}
|
||||
|
||||
trashPath := filepath.Join(root, ".verstak", "trash", "workspaces", "external-trash", "Renamed")
|
||||
if err := os.MkdirAll(filepath.Dir(trashPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Rename(filepath.Join(root, "Renamed"), trashPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.ScanAndRecord(); err != nil {
|
||||
t.Fatalf("scan trash: %v", err)
|
||||
}
|
||||
assertWorkspaceSnapshotOp(t, unpushedOps(t, service), 1, OpTrash, workspaceID, "Renamed", "")
|
||||
|
||||
if err := os.Rename(trashPath, filepath.Join(root, "Restored")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.ScanAndRecord(); err != nil {
|
||||
t.Fatalf("scan restore: %v", err)
|
||||
}
|
||||
assertWorkspaceSnapshotOp(t, unpushedOps(t, service), 2, OpRestore, workspaceID, "Restored", "")
|
||||
|
||||
createSnapshotWorkspace(t, root, "Copied", workspaceID)
|
||||
warnings, err := service.ScanAndRecord()
|
||||
if err != nil {
|
||||
t.Fatalf("scan duplicate identity: %v", err)
|
||||
}
|
||||
if len(warnings) != 1 || !strings.Contains(warnings[0], "duplicate-workspace-id: Copied") {
|
||||
t.Fatalf("duplicate identity warnings = %v", warnings)
|
||||
}
|
||||
if got := len(unpushedOps(t, service)); got != 3 {
|
||||
t.Fatalf("duplicate identity created operations: %d, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func createSnapshotWorkspace(t *testing.T, root, name, workspaceID string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Join(root, name, ".verstak"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
marker, err := json.Marshal(map[string]string{"workspaceId": workspaceID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, name, ".verstak", "workspace.json"), marker, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metadataPath := filepath.Join(root, ".verstak", "workspaces", name, "metadata.json")
|
||||
if err := os.MkdirAll(filepath.Dir(metadataPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(metadataPath, []byte(`{"workspaceId":"`+workspaceID+`","workspaceName":"`+name+`"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertWorkspaceSnapshotOp(t *testing.T, ops []Op, index int, opType, workspaceID, path, previousPath string) {
|
||||
t.Helper()
|
||||
if len(ops) <= index {
|
||||
t.Fatalf("operations = %#v, want index %d", ops, index)
|
||||
}
|
||||
op := ops[index]
|
||||
if op.EntityType != EntityWorkspace || op.EntityID != workspaceID || op.OpType != opType {
|
||||
t.Fatalf("workspace operation = %+v", op)
|
||||
}
|
||||
var payload snapshotWorkspacePayload
|
||||
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
||||
t.Fatalf("decode workspace payload: %v", err)
|
||||
}
|
||||
if payload.Path != path || payload.PreviousPath != previousPath || payload.WorkspaceID != workspaceID {
|
||||
t.Fatalf("workspace payload = %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanAndRecordBaselinesThenRecordsExternalChanges(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
service := NewService(root, "device-a")
|
||||
|
||||
warnings, err := service.ScanAndRecord()
|
||||
if err != nil {
|
||||
t.Fatalf("initial ScanAndRecord: %v", err)
|
||||
}
|
||||
if len(warnings) != 0 {
|
||||
t.Fatalf("initial warnings = %v, want none", warnings)
|
||||
}
|
||||
assertUnpushedCount(t, service, 0)
|
||||
|
||||
if err := os.Mkdir(filepath.Join(root, "Docs"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "Docs", "note.txt"), []byte("one"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
warnings, err = service.ScanAndRecord()
|
||||
if err != nil {
|
||||
t.Fatalf("scan create: %v", err)
|
||||
}
|
||||
if len(warnings) != 0 {
|
||||
t.Fatalf("create warnings = %v", warnings)
|
||||
}
|
||||
ops := unpushedOps(t, service)
|
||||
if len(ops) != 2 {
|
||||
t.Fatalf("create ops = %#v, want folder and file", ops)
|
||||
}
|
||||
if ops[0].EntityType != EntityFolder || ops[0].EntityID != "Docs" || ops[0].OpType != OpCreate {
|
||||
t.Fatalf("folder op = %+v", ops[0])
|
||||
}
|
||||
if ops[1].EntityType != EntityFile || ops[1].EntityID != "Docs/note.txt" || ops[1].OpType != OpCreate {
|
||||
t.Fatalf("file op = %+v", ops[1])
|
||||
}
|
||||
var createPayload map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(ops[1].PayloadJSON), &createPayload); err != nil {
|
||||
t.Fatalf("decode file payload: %v", err)
|
||||
}
|
||||
if createPayload["content"] != "one" || createPayload["contentHash"] == "" {
|
||||
t.Fatalf("file payload = %#v", createPayload)
|
||||
}
|
||||
|
||||
if _, err := service.ScanAndRecord(); err != nil {
|
||||
t.Fatalf("unchanged scan: %v", err)
|
||||
}
|
||||
assertUnpushedCount(t, service, 2)
|
||||
|
||||
if err := os.WriteFile(filepath.Join(root, "Docs", "note.txt"), []byte("two"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.ScanAndRecord(); err != nil {
|
||||
t.Fatalf("scan update: %v", err)
|
||||
}
|
||||
ops = unpushedOps(t, service)
|
||||
if len(ops) != 3 || ops[2].EntityType != EntityFile || ops[2].OpType != OpUpdate {
|
||||
t.Fatalf("update ops = %#v", ops)
|
||||
}
|
||||
|
||||
if err := os.Remove(filepath.Join(root, "Docs", "note.txt")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(root, "Docs")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.ScanAndRecord(); err != nil {
|
||||
t.Fatalf("scan delete: %v", err)
|
||||
}
|
||||
ops = unpushedOps(t, service)
|
||||
if len(ops) != 5 {
|
||||
t.Fatalf("delete ops = %#v", ops)
|
||||
}
|
||||
if ops[3].EntityType != EntityFile || ops[3].OpType != OpDelete || ops[4].EntityType != EntityFolder || ops[4].OpType != OpDelete {
|
||||
t.Fatalf("delete ordering = %#v", ops[3:])
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanAndRecordNeverTreatsInitialFilesAsDeletes(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, "Existing"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "Existing", "before-sync.txt"), []byte("local"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(root, "device-a")
|
||||
|
||||
if _, err := service.ScanAndRecord(); err != nil {
|
||||
t.Fatalf("initial ScanAndRecord: %v", err)
|
||||
}
|
||||
assertUnpushedCount(t, service, 0)
|
||||
snapshot, err := service.LoadSnapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSnapshot: %v", err)
|
||||
}
|
||||
if snapshot.Entries["Existing/before-sync.txt"].Hash == "" {
|
||||
t.Fatalf("snapshot = %#v, expected content hash", snapshot.Entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanAndRecordFindsChangesMadeWhileDesktopWasClosed(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
initial := NewService(root, "device-a")
|
||||
if _, err := initial.ScanAndRecord(); err != nil {
|
||||
t.Fatalf("initial baseline: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "offline.txt"), []byte("created while closed"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
restarted := NewService(root, "device-a")
|
||||
if _, err := restarted.ScanAndRecord(); err != nil {
|
||||
t.Fatalf("scan after offline create: %v", err)
|
||||
}
|
||||
ops := unpushedOps(t, restarted)
|
||||
if len(ops) != 1 || ops[0].OpType != OpCreate || ops[0].EntityID != "offline.txt" {
|
||||
t.Fatalf("offline create operations = %#v", ops)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(root, "offline.txt"), []byte("updated while closed"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restarted = NewService(root, "device-a")
|
||||
if _, err := restarted.ScanAndRecord(); err != nil {
|
||||
t.Fatalf("scan after offline update: %v", err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(root, "offline.txt")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restarted = NewService(root, "device-a")
|
||||
if _, err := restarted.ScanAndRecord(); err != nil {
|
||||
t.Fatalf("scan after offline delete: %v", err)
|
||||
}
|
||||
ops = unpushedOps(t, restarted)
|
||||
if len(ops) != 3 || ops[1].OpType != OpUpdate || ops[2].OpType != OpDelete {
|
||||
t.Fatalf("offline lifecycle operations = %#v", ops)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordBootstrapOpsPublishesExistingFilesWithoutDeletes(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, "Existing"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "Existing", "before-sync.txt"), []byte("local"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(root, "device-a")
|
||||
if _, err := service.ScanAndRecord(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
initial, err := service.LoadSnapshot()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.RecordBootstrapOps(initial); err != nil {
|
||||
t.Fatalf("RecordBootstrapOps: %v", err)
|
||||
}
|
||||
ops := unpushedOps(t, service)
|
||||
if len(ops) != 2 {
|
||||
t.Fatalf("bootstrap ops = %#v, want create folder and file", ops)
|
||||
}
|
||||
for _, op := range ops {
|
||||
if op.OpType != OpCreate {
|
||||
t.Fatalf("bootstrap op = %+v, initial scan must not create delete", op)
|
||||
}
|
||||
}
|
||||
|
||||
empty := newSnapshot()
|
||||
if err := service.RecordBootstrapOps(empty); err != nil {
|
||||
t.Fatalf("empty bootstrap: %v", err)
|
||||
}
|
||||
if got := len(unpushedOps(t, service)); got != 2 {
|
||||
t.Fatalf("empty bootstrap added operations = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncBootstrapAndWarningStateSurviveRestart(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
service := NewService(root, "device-a")
|
||||
if err := service.SetBootstrapComplete(true); err != nil {
|
||||
t.Fatalf("SetBootstrapComplete: %v", err)
|
||||
}
|
||||
if err := service.SetLastWarning("file-too-large: archive.bin"); err != nil {
|
||||
t.Fatalf("SetLastWarning: %v", err)
|
||||
}
|
||||
|
||||
restarted := NewService(root, "")
|
||||
bootstrapped, err := restarted.BootstrapComplete()
|
||||
if err != nil {
|
||||
t.Fatalf("BootstrapComplete: %v", err)
|
||||
}
|
||||
if !bootstrapped {
|
||||
t.Fatal("bootstrap state was lost after restart")
|
||||
}
|
||||
warning, err := restarted.LastWarning()
|
||||
if err != nil {
|
||||
t.Fatalf("LastWarning: %v", err)
|
||||
}
|
||||
if warning != "file-too-large: archive.bin" {
|
||||
t.Fatalf("warning = %q", warning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanAndRecordSkipsReservedTemporaryAndSymlinkPaths(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, ".verstak", "sync"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, ".verstak", "sync", "state.json"), []byte("{}"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, ".verstak-write-local"), []byte("temporary"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "draft.tmp"), []byte("temporary"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "normal.txt"), []byte("normal"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
if err := os.Symlink(filepath.Join(root, "normal.txt"), filepath.Join(root, "normal-link.txt")); err != nil {
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
service := NewService(root, "device-a")
|
||||
if _, err := service.ScanAndRecord(); err != nil {
|
||||
t.Fatalf("baseline: %v", err)
|
||||
}
|
||||
snapshot, err := service.LoadSnapshot()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{".verstak/sync/state.json", ".verstak-write-local", "draft.tmp", "normal-link.txt"} {
|
||||
if _, ok := snapshot.Entries[path]; ok {
|
||||
t.Fatalf("reserved path %q was included in snapshot %#v", path, snapshot.Entries)
|
||||
}
|
||||
}
|
||||
if _, ok := snapshot.Entries["normal.txt"]; !ok {
|
||||
t.Fatalf("normal file missing from snapshot %#v", snapshot.Entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanAndRecordKeepsUnsupportedFileUnresolved(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "too-large.bin")
|
||||
if err := os.WriteFile(path, make([]byte, maxOperationFileBytes+1), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(root, "device-a")
|
||||
|
||||
warnings, err := service.ScanAndRecord()
|
||||
if err != nil {
|
||||
t.Fatalf("scan unsupported file: %v", err)
|
||||
}
|
||||
if len(warnings) != 1 || !strings.Contains(warnings[0], "too-large.bin") || !strings.Contains(warnings[0], "file-too-large") {
|
||||
t.Fatalf("warnings = %v", warnings)
|
||||
}
|
||||
assertUnpushedCount(t, service, 0)
|
||||
snapshot, err := service.LoadSnapshot()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := snapshot.Entries["too-large.bin"]; ok {
|
||||
t.Fatalf("unsupported file was marked synchronized: %#v", snapshot.Entries)
|
||||
}
|
||||
|
||||
warnings, err = service.ScanAndRecord()
|
||||
if err != nil || len(warnings) != 1 {
|
||||
t.Fatalf("second scan warnings=%v err=%v, unresolved file must remain visible", warnings, err)
|
||||
}
|
||||
}
|
||||
|
||||
func unpushedOps(t *testing.T, service *Service) []Op {
|
||||
t.Helper()
|
||||
ops, err := service.GetUnpushedOps()
|
||||
if err != nil {
|
||||
t.Fatalf("GetUnpushedOps: %v", err)
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
func assertUnpushedCount(t *testing.T, service *Service, want int) {
|
||||
t.Helper()
|
||||
if got := len(unpushedOps(t, service)); got != want {
|
||||
t.Fatalf("unpushed ops = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user