blob: 0b9412b74a5bd16e0d0563cd783d98f9113f1605 [file]
// 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
}