blob: 1a5627f4f30d4e9b5cf2006411b0185d3baa1a0f [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 (
"crypto/sha256"
"encoding/hex"
"errors"
"maps"
"os"
"path/filepath"
"slices"
"strings"
"go.fuchsia.dev/jiri"
"go.fuchsia.dev/jiri/cipd"
"go.fuchsia.dev/jiri/osutil"
)
var errBrokenSymlink = errors.New("broken symlink found")
// fetchPackagesWithPackageCache fetches packages using a content-addressable cache.
// It groups packages by their resolved destination path, computes a combined hash
// of the package instances in each group, and either symlinks (or copies) the
// destination to the cached directory.
//
// Multiple packages can share the same destination directory. For example, a platform-specific
// package and a platform-independent package might both be extracted to the same
// directory (e.g., fuchsia/sdk/core/linux-amd64 and fuchsia/sdk/core/common both
// extracted to prebuilt/sdk/fuchsia). Grouping them ensures they are fetched
// together and merged into the shared cache directory.
func fetchPackagesWithPackageCache(jirix *jiri.X, pkgs Packages, fetchTimeout uint) error {
groups, err := groupPackages(jirix, pkgs)
if err != nil {
return err
}
destRelPaths := slices.Collect(maps.Keys(groups))
slices.Sort(destRelPaths)
sharedPackagesDir := jirix.PackageCacheDir()
for _, destRelPath := range destRelPaths {
if err := fetchAndCachePackageGroup(jirix, destRelPath, groups[destRelPath], sharedPackagesDir, fetchTimeout); err != nil {
return err
}
}
return nil
}
func fetchAndCachePackageGroup(jirix *jiri.X, destRelPath string, groupPkgs []Package, sharedPackagesDir string, fetchTimeout uint) error {
hash := computeGroupHash(groupPkgs)
cacheDir := filepath.Join(sharedPackagesDir, hash)
destDir := filepath.Join(jirix.Root, destRelPath)
if isHardLinkDestCorrect(destDir, cacheDir, hash) {
return nil
}
destFileInfo, destErr := os.Lstat(destDir)
destExists := destErr == nil
destIsSymlink := destExists && (destFileInfo.Mode()&os.ModeSymlink != 0)
// delete the cache contents if it is incorrect
cacheFileInfo, cacheErr := os.Lstat(cacheDir)
cacheExists := cacheErr == nil
cacheIncorrect := cacheExists && (cacheFileInfo.Mode()&os.ModeSymlink != 0 || !cacheFileInfo.IsDir())
if cacheIncorrect {
jirix.Logger.Warningf("Package cache %q is incorrect (not a directory or is a symlink). Removing it.", cacheDir)
if err := os.RemoveAll(cacheDir); err != nil {
return err
}
cacheExists = false
}
// create the parent of cache directory if it does not exist
if !cacheExists {
if err := os.MkdirAll(filepath.Dir(cacheDir), 0755); err != nil {
return err
}
}
// if the cache is missing, populate it
if !cacheExists {
if destExists && !destIsSymlink {
// Migrate from destDir to cacheDir
jirix.Logger.Infof("Migrating package %q to cache %q", destRelPath, cacheDir)
if err := osutil.Rename(destDir, cacheDir); err != nil {
return err
}
// If we migrated a hard-linked directory, it might contain the hash file.
// Remove it from the cache.
os.Remove(filepath.Join(cacheDir, ".jiri_cache_hash"))
if err := fixSymlinksInCache(jirix, cacheDir, cacheDir, destDir); err != nil {
return err
}
} else {
err := func() error {
jirix.TimerPush("download package: " + destRelPath)
defer jirix.TimerPop()
// Download from CIPD.
// Download to a temporary directory first and then rename to cacheDir.
// This ensures the cache population is atomic: if the download or mtime
// clamping is interrupted, we do not leave a corrupted cacheDir that
// subsequent runs would assume is correct. It also avoids rename conflicts
// if cacheDir were created beforehand.
tmpDir := filepath.Join(sharedPackagesDir, "tmp")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
return err
}
tempDir, err := os.MkdirTemp(tmpDir, "merged-*")
if err != nil {
return err
}
defer os.RemoveAll(tempDir)
tempPkgs := make(Packages)
for _, v := range groupPkgs {
pkgCopy := v
pkgCopy.Path = "."
newKey := PackageKey{name: v.Name, path: "."}
tempPkgs[newKey] = pkgCopy
}
ensureFilePath, err := generateEnsureFile(jirix, tempPkgs, !jirix.LockfileEnabled || jirix.UsingSnapshot, "")
if err != nil {
return err
}
defer os.Remove(ensureFilePath)
if jirix.LockfileEnabled && !jirix.UsingSnapshot {
versionFilePath, err := generateVersionFile(jirix, ensureFilePath, tempPkgs)
if err != nil {
return err
}
defer os.Remove(versionFilePath)
}
if err := cipd.Ensure(jirix, ensureFilePath, tempDir, fetchTimeout); err != nil {
return err
}
if err := fixSymlinksInCache(jirix, tempDir, cacheDir, destDir); err != nil {
return err
}
if err := osutil.Rename(tempDir, cacheDir); err != nil {
return err
}
return nil
}()
if err != nil {
return err
}
}
}
// 1. Ensure the parent directory of the destination exists.
if err := os.MkdirAll(filepath.Dir(destDir), 0755); err != nil {
return err
}
// 2. Clean up the destination path.
// Remove whatever is currently at destDir (could be an old symlink,
// an outdated version of the package, or a dirty directory).
if err := os.RemoveAll(destDir); err != nil {
return err
}
// 3. Create the hard links.
// Recursively walk the cacheDir and create hard links in destDir.
if err := osutil.LinkPath(cacheDir, destDir); err != nil {
return err
}
// 4. Fix internal relative symlinks.
// Re-resolve and adjust any symlinks inside the package that point
// outside the package directory.
if err := fixSymlinksAfterRestoration(jirix, destDir, cacheDir); err != nil && err != errBrokenSymlink {
return err
}
// 5. Write the cache verification metadata.
// Store the hash in a .jiri_cache_hash file to allow fast-path validation
// on subsequent updates.
hashFile := filepath.Join(destDir, ".jiri_cache_hash")
if err := os.WriteFile(hashFile, []byte(hash), 0644); err != nil {
return err
}
return nil
}
// groupPackages groups packages by their resolved local path.
func groupPackages(jirix *jiri.X, pkgs Packages) (map[string][]Package, error) {
groups := make(map[string][]Package)
for _, pkg := range pkgs {
resolvedPath, err := pkg.ResolvePath()
if err != nil {
return nil, err
}
groups[resolvedPath] = append(groups[resolvedPath], pkg)
}
return groups, nil
}
// computeGroupHash computes a hash of the package instances in a group.
func computeGroupHash(pkgs []Package) string {
specs := make([]string, len(pkgs))
for i, p := range pkgs {
specs[i] = p.Name + ":" + p.Version
}
slices.Sort(specs)
joined := strings.Join(specs, ",")
hash := sha256.Sum256([]byte(joined))
return hex.EncodeToString(hash[:])
}
// fixSymlinksInCache resolves relative symlinks inside a cached package
// relative to its original extraction directory and rewrites them so they
// resolve correctly from the cache directory.
// Only rewrites symlinks that point outside the package directory.
func fixSymlinksInCache(jirix *jiri.X, currentDir string, finalDir string, destDir string) error {
return filepath.WalkDir(currentDir, 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
}
if filepath.IsAbs(target) {
return nil
}
// Resolve the target relative to the directory containing it at the time we read it.
// If we are migrating, it is relative to destDir (because it was in destDir).
// If we are downloading, it is relative to currentDir (tempDir) (because it was installed in tempDir by CIPD).
var containingDir string
relPath, err := filepath.Rel(currentDir, path)
if err != nil {
return err
}
if currentDir == finalDir {
// Migration: symlink target is relative to destDir
originalSymlinkPath := filepath.Join(destDir, relPath)
containingDir = filepath.Dir(originalSymlinkPath)
} else {
// Download: symlink target is relative to currentDir (tempDir)
containingDir = filepath.Dir(path)
}
resolvedTarget := filepath.Clean(filepath.Join(containingDir, target))
// Check if the resolved target is INSIDE the package.
// For migration: package root was destDir.
// For download: package root was currentDir.
var packageRoot string
if currentDir == finalDir {
packageRoot = destDir
} else {
packageRoot = currentDir
}
packageRoot = filepath.Clean(packageRoot)
if strings.HasPrefix(resolvedTarget, packageRoot+string(filepath.Separator)) || resolvedTarget == packageRoot {
// The symlink points inside the package.
// Since the relative structure within the package is preserved,
// the original relative target is already correct.
// No rewrite needed!
jirix.Logger.Debugf("Keeping internal relative symlink in cache: %s -> %s", path, target)
return nil
}
// The symlink points outside the package (e.g. to .cipd/pkgs). We must rewrite it.
// Compute the new relative path from the final containing directory in cache:
finalSymlinkPath := filepath.Join(finalDir, relPath)
finalContainingDir := filepath.Dir(finalSymlinkPath)
newTarget, err := filepath.Rel(finalContainingDir, resolvedTarget)
if err != nil {
return err
}
// Recreate the symlink with the adjusted target
if err := os.Remove(path); err != nil {
return err
}
if err := os.Symlink(newTarget, path); err != nil {
return err
}
jirix.Logger.Debugf("Adjusted relative symlink in cache: %s -> %s (was %s)", path, newTarget, target)
return nil
})
}
// fixSymlinksAfterRestoration resolves relative symlinks inside a restored package
// relative to its cache directory and rewrites them so they resolve correctly
// from the destination directory.
// Only rewrites symlinks that point outside the package directory (in the cache).
// Returns errBrokenSymlink if any symlink is broken or cannot be resolved.
func fixSymlinksAfterRestoration(jirix *jiri.X, destDir string, cacheDir string) error {
destDirClean := filepath.Clean(destDir)
cacheDirClean := filepath.Clean(cacheDir)
return 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
}
// finalRelativeTarget is the relative path that will be written into the recreated symlink.
var finalRelativeTarget string
// finalAbsoluteTarget is the absolute path to the target file/directory, used to verify it exists.
var finalAbsoluteTarget string
var needToRewriteSymlink bool
if filepath.IsAbs(target) {
finalAbsoluteTarget = target
} else {
// Resolve the target relative to the cache path where it came from.
// Example:
// destDir = /root/dest
// cacheDir = /cache/hash
// path = /root/dest/bin/python (symlink target = "../lib/python3.11")
// relPath = "bin/python"
// cacheSymlinkPath = "/cache/hash/bin/python"
// cacheContainingDir = "/cache/hash/bin"
// resolvedTarget = "/cache/hash/lib/python3.11"
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)
// Map the resolved target from the cache directory to the destination directory.
// If the target is inside the cache directory, we preserve it as pointing inside the destination directory.
// Otherwise, it points outside the package, and we must adjust it.
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 {
jirix.Logger.Debugf("Keeping internal relative symlink after restoration: %s -> %s", path, target)
}
} else {
// The symlink points outside the package (e.g. to .cipd/pkgs). We must rewrite it.
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
}
jirix.Logger.Debugf("Adjusted relative symlink after restoration: %s -> %s (was %s)", path, finalRelativeTarget, target)
}
// Verify that the final resolved target exists
if _, err := os.Stat(finalAbsoluteTarget); os.IsNotExist(err) {
return errBrokenSymlink
}
return nil
})
}
// restorePackageFromCache attempts to restore a package from the package cache.
// If the destination path is currently a symlink (representing a cache-enabled package),
// it replaces it with a real directory containing a copy of the cached content to avoid download.
//
// If the destination is not a symlink, it returns nil immediately.
// If the cache exists and restoration succeeds, the symlink is replaced with the restored content.
// If restoration fails or the cache doesn't exist, the symlink is removed so that CIPD can download it.
// Returns an error if any fatal OS operations fail.
func restorePackageFromCache(jirix *jiri.X, pkg Package, groups map[string][]Package) error {
destRelPath, err := pkg.ResolvePath()
if err != nil {
return err
}
destDir := filepath.Join(jirix.Root, destRelPath)
destFileInfo, destErr := os.Lstat(destDir)
destExists := destErr == nil
destIsSymlink := destExists && destFileInfo.Mode()&os.ModeSymlink != 0
if !destIsSymlink {
return nil
}
// The destination is currently a symlink. We want it to be a real directory.
// Try to restore/migrate it from the cache to avoid download.
group := groups[destRelPath]
hash := computeGroupHash(group)
cacheDir := filepath.Join(jirix.PackageCacheDir(), hash)
if _, err := os.Stat(cacheDir); err == nil {
jirix.Logger.Infof("Restoring package %q from cache %q", destRelPath, cacheDir)
if err := os.Remove(destDir); err != nil {
return err
}
err := osutil.CopyPath(cacheDir, destDir)
if err == nil {
if err := fixSymlinksAfterRestoration(jirix, destDir, cacheDir); err == nil {
return nil
} else if err == errBrokenSymlink {
jirix.Logger.Warningf("Restored package %q has broken symlinks", destRelPath)
} else {
jirix.Logger.Warningf("Failed to fix symlinks for restored package %q: %v", destRelPath, err)
}
} else {
jirix.Logger.Warningf("CopyPath failed for restored package %q: %v", destRelPath, err)
}
}
// If restoration wasn't possible/failed, just delete the symlink and let cipd download.
jirix.Logger.Infof("Removing symlink %q for copy-only or cache-disabled package", destDir)
return os.RemoveAll(destDir)
}
// isPackageGroupCached checks if the given package group is already cached.
func isPackageGroupCached(jirix *jiri.X, groupPkgs []Package) bool {
sharedPackagesDir := jirix.PackageCacheDir()
hash := computeGroupHash(groupPkgs)
cacheDir := filepath.Join(sharedPackagesDir, hash)
cacheFileInfo, err := os.Lstat(cacheDir)
return err == nil && cacheFileInfo.IsDir() && cacheFileInfo.Mode()&os.ModeSymlink == 0
}
// partitionPackages splits packages into cached and uncached packages.
// Cached packages can skip ACL checks and downloads.
func partitionPackages(jirix *jiri.X, pkgs Packages) (Packages, Packages, error) {
cached := make(Packages)
uncached := make(Packages)
if !jirix.PackageCacheEnabled && !jirix.IsWorktree() {
return cached, pkgs, nil
}
groups, err := groupPackages(jirix, pkgs)
if err != nil {
return nil, nil, err
}
for destRelPath, groupPkgs := range groups {
if isPackageGroupCached(jirix, groupPkgs) {
jirix.Logger.Debugf("Package group %q is cached, skipping ACL check", destRelPath)
for _, pkg := range groupPkgs {
cached[pkg.Key()] = pkg
}
} else {
for _, pkg := range groupPkgs {
uncached[pkg.Key()] = pkg
}
}
}
return cached, uncached, nil
}
// isHardLinkDestCorrect verifies if the destination directory is a valid
// hard-linked package matching the expected cache directory and hash.
// It checks that destDir is a real directory (not a symlink), cacheDir exists,
// and the .jiri_cache_hash file in destDir matches expectedHash.
func isHardLinkDestCorrect(destDir, cacheDir, expectedHash string) bool {
fi, err := os.Lstat(destDir)
if err != nil || !fi.IsDir() || fi.Mode()&os.ModeSymlink != 0 {
return false
}
cfi, err := os.Lstat(cacheDir)
if err != nil || !cfi.IsDir() || cfi.Mode()&os.ModeSymlink != 0 {
return false
}
hashFile := filepath.Join(destDir, ".jiri_cache_hash")
data, err := os.ReadFile(hashFile)
if err != nil {
return false
}
return strings.TrimSpace(string(data)) == expectedHash
}