| // 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 |
| } |