Skip to content

Commit 3d80959

Browse files
committed
fix(build): preserve symlink semantics and support cross-device migration
1 parent 1705d48 commit 3d80959

File tree

2 files changed

+237
-8
lines changed

2 files changed

+237
-8
lines changed

cli/build/spec.go

Lines changed: 142 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
package build
22

33
import (
4+
"errors"
45
"fmt"
6+
"io"
57
"os"
68
"path/filepath"
79
"pig/internal/config"
810
"pig/internal/utils"
11+
"syscall"
912

1013
"github.com/sirupsen/logrus"
1114
)
1215

16+
var renamePath = os.Rename
17+
1318
// Spec configuration for build environments
1419
type specConfig struct {
1520
Type string // "rpm" or "deb"
@@ -102,7 +107,8 @@ func setupBuildDirs(spec *specConfig, force bool) error {
102107

103108
// createSymlink creates a symbolic link: linkPath -> target
104109
// In force mode, aggressively removes any existing file/dir/symlink at linkPath.
105-
// In non-force mode, existing directories are preserved to avoid accidental data loss.
110+
// In non-force mode, existing directories are migrated into target first, then
111+
// replaced by symlink to preserve data while keeping link semantics.
106112
func createSymlink(target, linkPath string, force bool) error {
107113
if info, err := os.Lstat(linkPath); err == nil {
108114
switch {
@@ -116,9 +122,14 @@ func createSymlink(target, linkPath string, force bool) error {
116122
}
117123
case info.IsDir():
118124
if !force {
119-
// Keep existing directories in non-force mode.
120-
logrus.Debugf("keeping existing directory in non-force mode: %s", linkPath)
121-
return nil
125+
// Preserve existing content by moving it into target before relinking.
126+
if err := migrateDirIntoTarget(linkPath, target); err != nil {
127+
return err
128+
}
129+
if err := os.Remove(linkPath); err != nil && !os.IsNotExist(err) {
130+
return fmt.Errorf("failed to remove existing directory after migration: %w", err)
131+
}
132+
break
122133
}
123134
if err := os.RemoveAll(linkPath); err != nil && !os.IsNotExist(err) {
124135
return fmt.Errorf("failed to remove existing directory: %w", err)
@@ -152,6 +163,133 @@ func createSymlink(target, linkPath string, force bool) error {
152163
return os.Symlink(target, linkPath)
153164
}
154165

166+
func migrateDirIntoTarget(srcDir, targetDir string) error {
167+
if srcDir == targetDir {
168+
return nil
169+
}
170+
if err := os.MkdirAll(targetDir, 0755); err != nil {
171+
return fmt.Errorf("failed to create target directory %s: %w", targetDir, err)
172+
}
173+
backupDir := filepath.Join(targetDir, ".migrated_from_"+filepath.Base(srcDir))
174+
175+
entries, err := os.ReadDir(srcDir)
176+
if err != nil {
177+
return fmt.Errorf("failed to read existing directory %s: %w", srcDir, err)
178+
}
179+
for _, entry := range entries {
180+
srcPath := filepath.Join(srcDir, entry.Name())
181+
dstPath := filepath.Join(targetDir, entry.Name())
182+
183+
if _, err := os.Lstat(dstPath); err == nil {
184+
// Keep target entry untouched; move source entry into backup to avoid data loss.
185+
if err := os.MkdirAll(backupDir, 0755); err != nil {
186+
return fmt.Errorf("failed to create migration backup directory %s: %w", backupDir, err)
187+
}
188+
backupPath, err := uniqueBackupPath(filepath.Join(backupDir, entry.Name()))
189+
if err != nil {
190+
return fmt.Errorf("failed to allocate migration backup path: %w", err)
191+
}
192+
if err := os.Rename(srcPath, backupPath); err != nil {
193+
return fmt.Errorf("failed to backup conflicting entry %s to %s: %w", srcPath, backupPath, err)
194+
}
195+
continue
196+
} else if !os.IsNotExist(err) {
197+
return fmt.Errorf("failed to inspect migration target %s: %w", dstPath, err)
198+
}
199+
200+
if err := movePath(srcPath, dstPath); err != nil {
201+
return fmt.Errorf("failed to migrate %s to %s: %w", srcPath, dstPath, err)
202+
}
203+
}
204+
return nil
205+
}
206+
207+
func movePath(src, dst string) error {
208+
if err := renamePath(src, dst); err == nil {
209+
return nil
210+
} else if !errors.Is(err, syscall.EXDEV) {
211+
return err
212+
}
213+
214+
if err := copyPath(src, dst); err != nil {
215+
return err
216+
}
217+
return os.RemoveAll(src)
218+
}
219+
220+
func copyPath(src, dst string) error {
221+
info, err := os.Lstat(src)
222+
if err != nil {
223+
return err
224+
}
225+
226+
switch mode := info.Mode(); {
227+
case mode&os.ModeSymlink != 0:
228+
target, err := os.Readlink(src)
229+
if err != nil {
230+
return err
231+
}
232+
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
233+
return err
234+
}
235+
return os.Symlink(target, dst)
236+
case info.IsDir():
237+
if err := os.MkdirAll(dst, info.Mode().Perm()); err != nil {
238+
return err
239+
}
240+
entries, err := os.ReadDir(src)
241+
if err != nil {
242+
return err
243+
}
244+
for _, entry := range entries {
245+
if err := copyPath(filepath.Join(src, entry.Name()), filepath.Join(dst, entry.Name())); err != nil {
246+
return err
247+
}
248+
}
249+
return nil
250+
case mode.IsRegular():
251+
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
252+
return err
253+
}
254+
in, err := os.Open(src)
255+
if err != nil {
256+
return err
257+
}
258+
defer in.Close()
259+
260+
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode().Perm())
261+
if err != nil {
262+
return err
263+
}
264+
defer out.Close()
265+
266+
if _, err := io.Copy(out, in); err != nil {
267+
return err
268+
}
269+
return out.Close()
270+
default:
271+
return fmt.Errorf("unsupported file mode for cross-device move: %s", info.Mode().String())
272+
}
273+
}
274+
275+
func uniqueBackupPath(path string) (string, error) {
276+
if _, err := os.Lstat(path); os.IsNotExist(err) {
277+
return path, nil
278+
} else if err != nil {
279+
return "", err
280+
}
281+
282+
for i := 1; i < 1000; i++ {
283+
candidate := fmt.Sprintf("%s.%d", path, i)
284+
if _, err := os.Lstat(candidate); os.IsNotExist(err) {
285+
return candidate, nil
286+
} else if err != nil {
287+
return "", err
288+
}
289+
}
290+
return "", fmt.Errorf("failed to allocate backup path for %s", path)
291+
}
292+
155293
// syncSpec: Download tarball and perform incremental sync via rsync
156294
func syncSpec(spec *specConfig, force bool, mirror bool) error {
157295
logrus.Info("sync extension specs")

cli/build/spec_test.go

Lines changed: 95 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@ package build
33
import (
44
"os"
55
"path/filepath"
6+
"syscall"
67
"testing"
78
)
89

9-
func TestCreateSymlinkNonForceKeepsExistingDirectory(t *testing.T) {
10+
func TestCreateSymlinkNonForceMigratesDirectoryThenLinks(t *testing.T) {
1011
tmp := t.TempDir()
1112
target := filepath.Join(tmp, "target")
1213
if err := os.MkdirAll(target, 0o755); err != nil {
@@ -30,11 +31,23 @@ func TestCreateSymlinkNonForceKeepsExistingDirectory(t *testing.T) {
3031
if err != nil {
3132
t.Fatalf("failed to stat link path: %v", err)
3233
}
33-
if info.Mode()&os.ModeSymlink != 0 {
34-
t.Fatalf("expected existing directory to be preserved in non-force mode")
34+
if info.Mode()&os.ModeSymlink == 0 {
35+
t.Fatalf("expected link path to become symlink in non-force mode")
36+
}
37+
realTarget, err := os.Readlink(linkPath)
38+
if err != nil {
39+
t.Fatalf("failed to read symlink target: %v", err)
40+
}
41+
if realTarget != target {
42+
t.Fatalf("symlink target mismatch: got %q want %q", realTarget, target)
43+
}
44+
45+
migratedMarker := filepath.Join(target, "marker.txt")
46+
if _, err := os.Stat(migratedMarker); err != nil {
47+
t.Fatalf("expected marker file to be migrated to target, got: %v", err)
3548
}
3649
if _, err := os.Stat(marker); err != nil {
37-
t.Fatalf("expected marker file to be preserved, got: %v", err)
50+
t.Fatalf("expected marker file to remain reachable via symlink, got: %v", err)
3851
}
3952
}
4053

@@ -76,3 +89,81 @@ func TestCreateSymlinkForceReplacesDirectory(t *testing.T) {
7689
t.Fatalf("expected old directory contents to be removed in force mode")
7790
}
7891
}
92+
93+
func TestCreateSymlinkNonForceConflictBackupsSourceThenLinks(t *testing.T) {
94+
tmp := t.TempDir()
95+
target := filepath.Join(tmp, "target")
96+
if err := os.MkdirAll(target, 0o755); err != nil {
97+
t.Fatalf("failed to create target directory: %v", err)
98+
}
99+
if err := os.WriteFile(filepath.Join(target, "marker.txt"), []byte("target"), 0o644); err != nil {
100+
t.Fatalf("failed to create target marker: %v", err)
101+
}
102+
103+
linkPath := filepath.Join(tmp, "link")
104+
if err := os.MkdirAll(linkPath, 0o755); err != nil {
105+
t.Fatalf("failed to create source dir: %v", err)
106+
}
107+
if err := os.WriteFile(filepath.Join(linkPath, "marker.txt"), []byte("source"), 0o644); err != nil {
108+
t.Fatalf("failed to create source marker: %v", err)
109+
}
110+
111+
if err := createSymlink(target, linkPath, false); err != nil {
112+
t.Fatalf("createSymlink(non-force) returned error: %v", err)
113+
}
114+
115+
info, err := os.Lstat(linkPath)
116+
if err != nil {
117+
t.Fatalf("failed to stat link path: %v", err)
118+
}
119+
if info.Mode()&os.ModeSymlink == 0 {
120+
t.Fatalf("expected link path to become symlink in non-force mode")
121+
}
122+
123+
targetContent, err := os.ReadFile(filepath.Join(target, "marker.txt"))
124+
if err != nil {
125+
t.Fatalf("failed to read target marker: %v", err)
126+
}
127+
if string(targetContent) != "target" {
128+
t.Fatalf("target marker should remain unchanged, got: %q", string(targetContent))
129+
}
130+
131+
matches, err := filepath.Glob(filepath.Join(target, ".migrated_from_link", "marker.txt*"))
132+
if err != nil {
133+
t.Fatalf("glob migration backup failed: %v", err)
134+
}
135+
if len(matches) == 0 {
136+
t.Fatalf("expected conflicted source marker to be backed up")
137+
}
138+
}
139+
140+
func TestMovePathCrossDeviceFallback(t *testing.T) {
141+
tmp := t.TempDir()
142+
src := filepath.Join(tmp, "src.txt")
143+
dst := filepath.Join(tmp, "dst.txt")
144+
145+
if err := os.WriteFile(src, []byte("hello"), 0o644); err != nil {
146+
t.Fatalf("failed to create source file: %v", err)
147+
}
148+
149+
oldRename := renamePath
150+
defer func() { renamePath = oldRename }()
151+
renamePath = func(oldPath, newPath string) error {
152+
return &os.LinkError{Op: "rename", Old: oldPath, New: newPath, Err: syscall.EXDEV}
153+
}
154+
155+
if err := movePath(src, dst); err != nil {
156+
t.Fatalf("movePath returned error: %v", err)
157+
}
158+
159+
if _, err := os.Stat(src); !os.IsNotExist(err) {
160+
t.Fatalf("source should be removed after fallback move")
161+
}
162+
content, err := os.ReadFile(dst)
163+
if err != nil {
164+
t.Fatalf("failed to read destination file: %v", err)
165+
}
166+
if string(content) != "hello" {
167+
t.Fatalf("unexpected destination content: %q", string(content))
168+
}
169+
}

0 commit comments

Comments
 (0)