Skip to content

Commit 57ac723

Browse files
authored
fix: improve safety for unsync copy directories (#25) (#25)
1 parent 78e8aee commit 57ac723

4 files changed

Lines changed: 186 additions & 5 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@ module github.com/laggu/git-volume
33
go 1.23.0
44

55
require (
6+
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
67
github.com/spf13/cobra v1.10.2
78
github.com/stretchr/testify v1.11.1
89
gopkg.in/yaml.v3 v3.0.1
910
)
1011

1112
require (
1213
github.com/davecgh/go-spew v1.1.1 // indirect
13-
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
1414
github.com/inconshreveable/mousetrap v1.1.0 // indirect
1515
github.com/kr/pretty v0.3.1 // indirect
1616
github.com/pmezard/go-difflib v1.0.0 // indirect

internal/gitvolume/unsync.go

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,10 +111,24 @@ func (g *GitVolume) checkRemovable(vol Volume) (bool, error) {
111111
}
112112

113113
if vol.Mode == ModeCopy {
114-
// Copy Mode: Check Hash
114+
// Copy Mode: Check content hash (file or directory)
115+
srcInfo, err := os.Stat(vol.SourcePath)
116+
if err != nil {
117+
return false, fmt.Errorf("could not verify content (source missing?)")
118+
}
119+
if srcInfo.IsDir() {
120+
if !info.IsDir() {
121+
return false, nil // type mismatch: source is dir but target is not
122+
}
123+
match, err := verifyDirHash(vol.SourcePath, vol.TargetPath)
124+
if err != nil {
125+
return false, fmt.Errorf("could not verify directory hash: %w", err)
126+
}
127+
return match, nil
128+
}
115129
match, err := verifyHash(vol.SourcePath, vol.TargetPath)
116130
if err != nil {
117-
return false, fmt.Errorf("could not verify hash (source missing?)")
131+
return false, fmt.Errorf("could not verify file hash: %w", err)
118132
}
119133
return match, nil
120134
}
@@ -137,9 +151,21 @@ func (g *GitVolume) checkRemovable(vol Volume) (bool, error) {
137151
}
138152

139153
func (g *GitVolume) removeVolume(vol Volume) error {
140-
if err := os.Remove(vol.TargetPath); err != nil {
141-
return fmt.Errorf("failed to remove %s: %w", vol.TargetPath, err)
154+
info, err := os.Lstat(vol.TargetPath)
155+
if err != nil {
156+
return fmt.Errorf("failed to stat %s: %w", vol.TargetPath, err)
142157
}
158+
159+
if info.IsDir() {
160+
if err := os.RemoveAll(vol.TargetPath); err != nil {
161+
return fmt.Errorf("failed to remove directory %s: %w", vol.TargetPath, err)
162+
}
163+
} else {
164+
if err := os.Remove(vol.TargetPath); err != nil {
165+
return fmt.Errorf("failed to remove %s: %w", vol.TargetPath, err)
166+
}
167+
}
168+
143169
if !g.quiet {
144170
fmt.Printf("✓ Removed %s\n", vol.Target)
145171
}

internal/gitvolume/unsync_test.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,3 +258,96 @@ func TestGitVolume_Unsync_RelativeLink(t *testing.T) {
258258
_, err = os.Lstat(linkPath)
259259
assert.True(t, os.IsNotExist(err), "Relative link should be correctly identified and removed")
260260
}
261+
262+
func TestGitVolume_Unsync_CopyDirectory(t *testing.T) {
263+
sourceDir, targetDir, cleanup := setupTestEnv(t)
264+
defer cleanup()
265+
266+
// Create source directory with files
267+
configDir := filepath.Join(sourceDir, "config")
268+
require.NoError(t, os.MkdirAll(configDir, 0755))
269+
require.NoError(t, os.WriteFile(filepath.Join(configDir, "app.env"), []byte("A=1"), 0644))
270+
require.NoError(t, os.WriteFile(filepath.Join(configDir, "db.env"), []byte("B=2"), 0644))
271+
272+
volumes := []Volume{
273+
{Source: "config", Target: "config", Mode: ModeCopy},
274+
}
275+
gv := createTestGitVolume(sourceDir, targetDir, "", volumes)
276+
277+
// Sync
278+
require.NoError(t, gv.Sync(SyncOptions{}))
279+
280+
// Verify synced
281+
targetConfig := filepath.Join(targetDir, "config")
282+
_, err := os.Stat(filepath.Join(targetConfig, "app.env"))
283+
require.NoError(t, err, "app.env should exist after sync")
284+
_, err = os.Stat(filepath.Join(targetConfig, "db.env"))
285+
require.NoError(t, err, "db.env should exist after sync")
286+
287+
// Unsync
288+
require.NoError(t, gv.Unsync(UnsyncOptions{}))
289+
290+
// Verify directory is completely removed
291+
_, err = os.Stat(targetConfig)
292+
assert.True(t, os.IsNotExist(err), "config directory should be removed after unsync")
293+
}
294+
295+
func TestGitVolume_Unsync_CopyDirectory_Modified(t *testing.T) {
296+
sourceDir, targetDir, cleanup := setupTestEnv(t)
297+
defer cleanup()
298+
299+
// Create source directory
300+
configDir := filepath.Join(sourceDir, "config")
301+
require.NoError(t, os.MkdirAll(configDir, 0755))
302+
require.NoError(t, os.WriteFile(filepath.Join(configDir, "app.env"), []byte("A=1"), 0644))
303+
304+
volumes := []Volume{
305+
{Source: "config", Target: "config", Mode: ModeCopy},
306+
}
307+
gv := createTestGitVolume(sourceDir, targetDir, "", volumes)
308+
309+
// Sync
310+
require.NoError(t, gv.Sync(SyncOptions{}))
311+
312+
// Modify a file inside the copied directory
313+
modifiedPath := filepath.Join(targetDir, "config", "app.env")
314+
require.NoError(t, os.WriteFile(modifiedPath, []byte("MODIFIED"), 0644))
315+
316+
// Unsync
317+
require.NoError(t, gv.Unsync(UnsyncOptions{}))
318+
319+
// Verify directory was preserved (hash mismatch → skip)
320+
_, err := os.Stat(filepath.Join(targetDir, "config"))
321+
assert.NoError(t, err, "Modified config directory should NOT be removed")
322+
323+
data, _ := os.ReadFile(modifiedPath)
324+
assert.Equal(t, "MODIFIED", string(data), "Modified file content should be preserved")
325+
}
326+
327+
func TestGitVolume_Unsync_CopyDirectory_MissingSource(t *testing.T) {
328+
sourceDir, targetDir, cleanup := setupTestEnv(t)
329+
defer cleanup()
330+
331+
// Create source directory
332+
configDir := filepath.Join(sourceDir, "config")
333+
require.NoError(t, os.MkdirAll(configDir, 0755))
334+
require.NoError(t, os.WriteFile(filepath.Join(configDir, "app.env"), []byte("A=1"), 0644))
335+
336+
volumes := []Volume{
337+
{Source: "config", Target: "config", Mode: ModeCopy},
338+
}
339+
gv := createTestGitVolume(sourceDir, targetDir, "", volumes)
340+
341+
// Sync
342+
require.NoError(t, gv.Sync(SyncOptions{}))
343+
344+
// Delete source directory
345+
require.NoError(t, os.RemoveAll(configDir))
346+
347+
// Unsync
348+
require.NoError(t, gv.Unsync(UnsyncOptions{}))
349+
350+
// Verify target directory was NOT removed (source missing → safe skip)
351+
_, err := os.Stat(filepath.Join(targetDir, "config"))
352+
assert.NoError(t, err, "Config directory should NOT be removed if source is missing")
353+
}

test/integration.sh

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,68 @@ else
322322
fail "global remove allowed path traversal (unexpected)"
323323
fi
324324

325+
# -----------------------------------------------------------------------------
326+
# Test: unsync copy directory
327+
# -----------------------------------------------------------------------------
328+
log "TEST" "Testing 'unsync' copy directory..."
329+
330+
# Create source directory with files
331+
mkdir -p src_dir/nested
332+
echo "FILE_A" > src_dir/a.txt
333+
echo "FILE_B" > src_dir/nested/b.txt
334+
335+
# Update config for directory copy
336+
cat > git-volume.yaml <<EOF
337+
volumes:
338+
- mount: "src_dir:copied_dir"
339+
mode: copy
340+
EOF
341+
342+
# Sync
343+
"$GV_BIN" sync -q
344+
345+
# Verify directory was copied
346+
if [[ -d "copied_dir" && -f "copied_dir/a.txt" && -f "copied_dir/nested/b.txt" ]]; then
347+
pass "sync created copy directory correctly"
348+
else
349+
fail "sync failed to copy directory"
350+
fi
351+
352+
# Unsync (unmodified directory should be removed)
353+
"$GV_BIN" unsync -q
354+
355+
if [[ ! -d "copied_dir" ]]; then
356+
pass "unsync removed copy directory"
357+
else
358+
fail "unsync failed to remove copy directory"
359+
fi
360+
361+
# Test: unsync preserves modified copy directory
362+
log "TEST" "Testing 'unsync' preserves modified copy directory..."
363+
364+
# Re-sync
365+
"$GV_BIN" sync -q
366+
367+
# Modify a file inside the copied directory
368+
echo "MODIFIED" > copied_dir/a.txt
369+
370+
# Unsync
371+
"$GV_BIN" unsync -q
372+
373+
if [[ -d "copied_dir" ]]; then
374+
CONTENT=$(cat copied_dir/a.txt)
375+
if [[ "$CONTENT" == "MODIFIED" ]]; then
376+
pass "unsync preserved modified copy directory"
377+
else
378+
fail "unsync changed modified copy directory content"
379+
fi
380+
else
381+
fail "unsync deleted modified copy directory"
382+
fi
383+
384+
# Clean up for next tests
385+
rm -rf copied_dir
386+
325387
# -----------------------------------------------------------------------------
326388
# Summary
327389
# -----------------------------------------------------------------------------

0 commit comments

Comments
 (0)