| // Copyright 2026 The Fuchsia Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| package project |
| |
| import ( |
| "context" |
| "fmt" |
| "os" |
| "path/filepath" |
| "strings" |
| |
| "go.fuchsia.dev/jiri/log" |
| ) |
| |
| var errBrokenSymlink = fmt.Errorf("broken symlink found") |
| |
| // linkDirectoryTree creates a mirrored directory tree at dst where all regular |
| // files are hard links pointing to corresponding files in src. |
| // An optional extFilter file extension suffix can be provided to skip matching directories. |
| func linkDirectoryTree(src, dst, extFilter string) error { |
| return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { |
| if err != nil { |
| return err |
| } |
| rel, err := filepath.Rel(src, path) |
| if err != nil { |
| return err |
| } |
| targetPath := filepath.Join(dst, rel) |
| if info.IsDir() { |
| if extFilter != "" && strings.HasSuffix(info.Name(), extFilter) { |
| return filepath.SkipDir |
| } |
| return os.MkdirAll(targetPath, 0755) |
| } |
| if info.Mode()&os.ModeSymlink != 0 { |
| target, err := os.Readlink(path) |
| if err != nil { |
| return err |
| } |
| absTarget := target |
| if !filepath.IsAbs(target) { |
| absTarget = filepath.Clean(filepath.Join(filepath.Dir(path), target)) |
| } |
| // When adopting from legacy caches where CIPD deployed files as symlinks |
| // pointing into .cipd/pkgs, resolve the symlink to the underlying regular |
| // file and hardlink it directly. This ensures the adopted instance is |
| // self-contained and avoids dangling symlinks when the source .cipd directory |
| // is retired. Internal symlinks that do not point into .cipd/pkgs are preserved as symlinks. |
| if strings.Contains(absTarget, ".cipd"+string(filepath.Separator)+"pkgs") { |
| if resolved, err := filepath.EvalSymlinks(path); err == nil { |
| if rfi, err := os.Stat(resolved); err == nil && !rfi.IsDir() { |
| return os.Link(resolved, targetPath) |
| } |
| } |
| } |
| return os.Symlink(target, targetPath) |
| } |
| return os.Link(path, targetPath) |
| }) |
| } |
| |
| // copyTreeWithHardlinks creates a hard-linked copy of src at dst without filtering. |
| func copyTreeWithHardlinks(src, dst string) error { |
| return linkDirectoryTree(src, dst, "") |
| } |
| |
| // linkDirectoryTreeWithOverwrite hard-links files from src into dst, overwriting |
| // any colliding files at dst. This enables multi-package destination layering. |
| func linkDirectoryTreeWithOverwrite(src, dst string) error { |
| if err := os.MkdirAll(dst, 0755); err != nil { |
| return err |
| } |
| return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { |
| if err != nil { |
| return err |
| } |
| rel, err := filepath.Rel(src, path) |
| if err != nil { |
| return err |
| } |
| targetPath := filepath.Join(dst, rel) |
| if info.IsDir() { |
| return os.MkdirAll(targetPath, 0755) |
| } |
| if info.Mode()&os.ModeSymlink != 0 { |
| target, err := os.Readlink(path) |
| if err != nil { |
| return err |
| } |
| os.Remove(targetPath) |
| return os.Symlink(target, targetPath) |
| } |
| os.Remove(targetPath) |
| return os.Link(path, targetPath) |
| }) |
| } |
| |
| // fixSymlinksAfterRestoration inspects symlinks in a restored or hard-linked workspace |
| // directory and rewrites any relative symlinks whose targets resolved within the source |
| // package instance to point to the corresponding paths in the destination directory. |
| func fixSymlinksAfterRestoration(ctx context.Context, destDir string, cache *PackageCache) error { |
| destDirClean := filepath.Clean(destDir) |
| // All cached instances reside at <cache.Root()>/instances/<instance_id> at the same |
| // directory depth. Since cacheDirClean is only used to check whether a relative symlink |
| // stays within the package root (isTargetInPkg) or escapes into the shared .cipd |
| // directory—both of which depend only on directory depth—any placeholder instance |
| // directory under instances/ yields the same relative resolution for all symlinks in destDir. |
| cacheDirClean := filepath.Clean(cache.InstanceDir(PackageCacheInstance{InstanceID: "_"})) |
| // Track broken symlinks and defer returning errBrokenSymlink until the walk completes. |
| // Callers like WorkspaceDestination.Link ignore errBrokenSymlink to tolerate packages |
| // that intentionally contain dangling symlinks, so we must finish adjusting all remaining |
| // symlinks in destDir rather than aborting on the first broken one. |
| hasBrokenSymlink := false |
| |
| err := filepath.WalkDir(destDir, func(path string, d os.DirEntry, err error) error { |
| if err != nil { |
| return err |
| } |
| if d.Type()&os.ModeSymlink == 0 { |
| return nil |
| } |
| target, err := os.Readlink(path) |
| if err != nil { |
| return err |
| } |
| |
| var finalRelativeTarget string |
| var finalAbsoluteTarget string |
| var needToRewriteSymlink bool |
| if filepath.IsAbs(target) { |
| finalAbsoluteTarget = target |
| } else { |
| relPath, err := filepath.Rel(destDir, path) |
| if err != nil { |
| return err |
| } |
| cacheSymlinkPath := filepath.Join(cacheDirClean, relPath) |
| cacheContainingDir := filepath.Dir(cacheSymlinkPath) |
| resolvedTarget := filepath.Clean(filepath.Join(cacheContainingDir, target)) |
| destContainingDir := filepath.Dir(path) |
| |
| relTargetInPkg, err := filepath.Rel(cacheDirClean, resolvedTarget) |
| isTargetInPkg := err == nil && !strings.HasPrefix(relTargetInPkg, "..") |
| if isTargetInPkg { |
| newResolvedTarget := filepath.Join(destDirClean, relTargetInPkg) |
| finalRelativeTarget, err = filepath.Rel(destContainingDir, newResolvedTarget) |
| if err != nil { |
| return err |
| } |
| finalAbsoluteTarget = newResolvedTarget |
| needToRewriteSymlink = (finalRelativeTarget != target) |
| if !needToRewriteSymlink { |
| log.Debugf(ctx, "Keeping internal relative symlink after restoration: %s -> %s", path, target) |
| } |
| } else { |
| finalRelativeTarget, err = filepath.Rel(destContainingDir, resolvedTarget) |
| if err != nil { |
| return err |
| } |
| finalAbsoluteTarget = resolvedTarget |
| needToRewriteSymlink = true |
| } |
| } |
| |
| if needToRewriteSymlink { |
| if err := os.Remove(path); err != nil { |
| return err |
| } |
| if err := os.Symlink(finalRelativeTarget, path); err != nil { |
| return err |
| } |
| log.Debugf(ctx, "Adjusted relative symlink after restoration: %s -> %s (was %s)", path, finalRelativeTarget, target) |
| } |
| |
| if _, err := os.Stat(finalAbsoluteTarget); os.IsNotExist(err) { |
| hasBrokenSymlink = true |
| } |
| return nil |
| }) |
| if err != nil { |
| return err |
| } |
| if hasBrokenSymlink { |
| return errBrokenSymlink |
| } |
| return nil |
| } |