-
Notifications
You must be signed in to change notification settings - Fork 1
feat: smart sync with change detection #70
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a0aa50a
6b8befe
b7580df
4268323
d8bdae9
887f185
a909f38
d65f6e6
201d906
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,74 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||
| package sync | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| import ( | ||||||||||||||||||||||||||||||||||||||||||||||||||
| "crypto/sha256" | ||||||||||||||||||||||||||||||||||||||||||||||||||
| "encoding/json" | ||||||||||||||||||||||||||||||||||||||||||||||||||
| "fmt" | ||||||||||||||||||||||||||||||||||||||||||||||||||
| "io" | ||||||||||||||||||||||||||||||||||||||||||||||||||
| "os" | ||||||||||||||||||||||||||||||||||||||||||||||||||
| "path/filepath" | ||||||||||||||||||||||||||||||||||||||||||||||||||
| "time" | ||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| const syncStateFile = ".sync-state" | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| // SyncState tracks sync metadata to avoid unnecessary rclone operations. | ||||||||||||||||||||||||||||||||||||||||||||||||||
| type SyncState struct { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| LastPushHash string `json:"last_push_hash"` | ||||||||||||||||||||||||||||||||||||||||||||||||||
| LastPushTime time.Time `json:"last_push_time"` | ||||||||||||||||||||||||||||||||||||||||||||||||||
| RemoteModTime time.Time `json:"remote_mod_time"` | ||||||||||||||||||||||||||||||||||||||||||||||||||
| RemoteSize int64 `json:"remote_size"` | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| // LoadState reads the sync state from the vault directory. | ||||||||||||||||||||||||||||||||||||||||||||||||||
| // Returns a zero-value SyncState if the file doesn't exist. | ||||||||||||||||||||||||||||||||||||||||||||||||||
| func LoadState(vaultDir string) (*SyncState, error) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| path := filepath.Join(vaultDir, syncStateFile) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| data, err := os.ReadFile(path) // #nosec G304 -- path is constructed from user-configured vault dir | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if os.IsNotExist(err) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return &SyncState{}, nil | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return nil, fmt.Errorf("failed to read sync state: %w", err) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| var state SyncState | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if err := json.Unmarshal(data, &state); err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return nil, fmt.Errorf("failed to parse sync state: %w", err) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return &state, nil | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| // SaveState writes the sync state to the vault directory. | ||||||||||||||||||||||||||||||||||||||||||||||||||
| func SaveState(vaultDir string, state *SyncState) error { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| data, err := json.MarshalIndent(state, "", " ") | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return fmt.Errorf("failed to marshal sync state: %w", err) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| path := filepath.Join(vaultDir, syncStateFile) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if err := os.WriteFile(path, data, 0600); err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||||||
Check failureCode scanning / CodeQL Uncontrolled data used in path expression High
This path depends on a
user-provided value Error loading related location Loading This path depends on a user-provided value Error loading related location Loading This path depends on a user-provided value Error loading related location Loading This path depends on a user-provided value Error loading related location Loading This path depends on a user-provided value Error loading related location Loading This path depends on a user-provided value Error loading related location Loading This path depends on a user-provided value Error loading related location Loading This path depends on a user-provided value Error loading related location Loading Copilot AutofixAI 2 months ago Copilot could not generate an autofix suggestion Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support. |
||||||||||||||||||||||||||||||||||||||||||||||||||
| return fmt.Errorf("failed to write sync state: %w", err) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return nil | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| // HashFile computes the SHA-256 hex digest of the file at the given path. | ||||||||||||||||||||||||||||||||||||||||||||||||||
| func HashFile(path string) (string, error) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| f, err := os.Open(path) // #nosec G304 -- path is user-configured vault file | ||||||||||||||||||||||||||||||||||||||||||||||||||
Check failureCode scanning / CodeQL Uncontrolled data used in path expression High
This path depends on a
user-provided value Error loading related location Loading This path depends on a user-provided value Error loading related location Loading This path depends on a user-provided value Error loading related location Loading This path depends on a user-provided value Error loading related location Loading This path depends on a user-provided value Error loading related location Loading This path depends on a user-provided value Error loading related location Loading This path depends on a user-provided value Error loading related location Loading This path depends on a user-provided value Error loading related location Loading
Copilot AutofixAI 2 months ago General approach: Introduce centralized validation/normalization for vault paths and directories, and apply it before using them in file I/O and hashing. The validation should (a) normalize to absolute paths, (b) reject or sanitize obviously dangerous patterns ( Best concrete fix with minimal behavior change:
Because you asked for a fix centered on
Suggested changeset
1
internal/sync/state.go
Copilot is powered by AI and may make mistakes. Always verify output.
Refresh and try again.
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return "", fmt.Errorf("failed to open file for hashing: %w", err) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| defer func() { _ = f.Close() }() | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| h := sha256.New() | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if _, err := io.Copy(h, f); err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return "", fmt.Errorf("failed to hash file: %w", err) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return fmt.Sprintf("%x", h.Sum(nil)), nil | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| // StatePath returns the full path to the sync state file in a vault directory. | ||||||||||||||||||||||||||||||||||||||||||||||||||
| func StatePath(vaultDir string) string { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return filepath.Join(vaultDir, syncStateFile) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| package sync | ||
|
|
||
| import ( | ||
| "os" | ||
| "path/filepath" | ||
| "testing" | ||
| "time" | ||
| ) | ||
|
|
||
| func TestLoadState_NoFile(t *testing.T) { | ||
| tmpDir := t.TempDir() | ||
| state, err := LoadState(tmpDir) | ||
| if err != nil { | ||
| t.Fatalf("LoadState with no file returned error: %v", err) | ||
| } | ||
| if state.LastPushHash != "" { | ||
| t.Errorf("expected empty LastPushHash, got %q", state.LastPushHash) | ||
| } | ||
| } | ||
|
|
||
| func TestSaveAndLoadState(t *testing.T) { | ||
| tmpDir := t.TempDir() | ||
| now := time.Now().Truncate(time.Second) | ||
|
|
||
| original := &SyncState{ | ||
| LastPushHash: "abc123", | ||
| LastPushTime: now, | ||
| RemoteModTime: now.Add(-time.Hour), | ||
| RemoteSize: 12345, | ||
| } | ||
|
|
||
| if err := SaveState(tmpDir, original); err != nil { | ||
| t.Fatalf("SaveState failed: %v", err) | ||
| } | ||
|
|
||
| loaded, err := LoadState(tmpDir) | ||
| if err != nil { | ||
| t.Fatalf("LoadState failed: %v", err) | ||
| } | ||
|
|
||
| if loaded.LastPushHash != original.LastPushHash { | ||
| t.Errorf("LastPushHash = %q, want %q", loaded.LastPushHash, original.LastPushHash) | ||
| } | ||
| if loaded.RemoteSize != original.RemoteSize { | ||
| t.Errorf("RemoteSize = %d, want %d", loaded.RemoteSize, original.RemoteSize) | ||
| } | ||
| } | ||
|
|
||
| func TestLoadState_CorruptedFile(t *testing.T) { | ||
| tmpDir := t.TempDir() | ||
| path := filepath.Join(tmpDir, syncStateFile) | ||
| if err := os.WriteFile(path, []byte("not json"), 0600); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
|
|
||
| _, err := LoadState(tmpDir) | ||
| if err == nil { | ||
| t.Error("expected error for corrupted state file") | ||
| } | ||
| } | ||
|
|
||
| func TestHashFile(t *testing.T) { | ||
| tmpDir := t.TempDir() | ||
| path := filepath.Join(tmpDir, "test.bin") | ||
| if err := os.WriteFile(path, []byte("hello world"), 0600); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
|
|
||
| hash, err := HashFile(path) | ||
| if err != nil { | ||
| t.Fatalf("HashFile failed: %v", err) | ||
| } | ||
|
|
||
| // SHA-256 of "hello world" | ||
| expected := "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" | ||
| if hash != expected { | ||
| t.Errorf("hash = %q, want %q", hash, expected) | ||
| } | ||
| } | ||
|
|
||
| func TestHashFile_NotExist(t *testing.T) { | ||
| _, err := HashFile("/nonexistent/file") | ||
| if err == nil { | ||
| t.Error("expected error for non-existent file") | ||
| } | ||
| } | ||
|
|
||
| func TestHashFile_DifferentContent(t *testing.T) { | ||
| tmpDir := t.TempDir() | ||
|
|
||
| path1 := filepath.Join(tmpDir, "a.bin") | ||
| path2 := filepath.Join(tmpDir, "b.bin") | ||
| _ = os.WriteFile(path1, []byte("content-a"), 0600) | ||
| _ = os.WriteFile(path2, []byte("content-b"), 0600) | ||
|
|
||
| hash1, _ := HashFile(path1) | ||
| hash2, _ := HashFile(path2) | ||
|
|
||
| if hash1 == hash2 { | ||
| t.Error("different files should have different hashes") | ||
| } | ||
| } | ||
|
|
||
| func TestStatePath(t *testing.T) { | ||
| got := StatePath("/home/user/.pass-cli") | ||
| expected := filepath.Join("/home/user/.pass-cli", ".sync-state") | ||
| if got != expected { | ||
| t.Errorf("StatePath = %q, want %q", got, expected) | ||
| } | ||
| } |
Check failure
Code scanning / CodeQL
Uncontrolled data used in path expression High