| // Copyright 2024 The Bazel Authors. All rights reserved. |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| package com.google.devtools.build.lib.actions; |
| |
| import static com.google.common.base.Preconditions.checkNotNull; |
| import static com.google.common.base.Preconditions.checkState; |
| |
| import com.google.common.base.MoreObjects; |
| import com.google.common.collect.ImmutableList; |
| import com.google.common.flogger.GoogleLogger; |
| import com.google.devtools.build.lib.vfs.DigestHashFunction; |
| import com.google.devtools.build.lib.vfs.FileSystemUtils; |
| import com.google.devtools.build.lib.vfs.Path; |
| import com.google.devtools.build.lib.vfs.PathFragment; |
| import com.google.devtools.build.lib.vfs.inmemoryfs.InMemoryFileSystem; |
| import java.io.IOException; |
| import java.util.HashMap; |
| import java.util.LinkedHashMap; |
| import java.util.LinkedHashSet; |
| import java.util.Map; |
| import javax.annotation.Nullable; |
| |
| /** A collection of {@link FilesetOutputSymlink}s comprising the output tree of a fileset. */ |
| public final class FilesetOutputTree { |
| |
| private static final GoogleLogger logger = GoogleLogger.forEnclosingClass(); |
| |
| public static final FilesetOutputTree EMPTY = new FilesetOutputTree(ImmutableList.of(), false); |
| private static final int MAX_SYMLINK_TRAVERSALS = 256; |
| |
| public static FilesetOutputTree create(ImmutableList<FilesetOutputSymlink> symlinks) { |
| return symlinks.isEmpty() |
| ? EMPTY |
| : new FilesetOutputTree( |
| symlinks, symlinks.stream().anyMatch(FilesetOutputTree::isRelativeSymlink)); |
| } |
| |
| private final ImmutableList<FilesetOutputSymlink> symlinks; |
| private final boolean hasRelativeSymlinks; |
| |
| private FilesetOutputTree( |
| ImmutableList<FilesetOutputSymlink> symlinks, boolean hasRelativeSymlinks) { |
| this.symlinks = checkNotNull(symlinks); |
| this.hasRelativeSymlinks = hasRelativeSymlinks; |
| } |
| |
| /** Receiver for the symlinks in a fileset's output tree. */ |
| @FunctionalInterface |
| public interface Visitor<E1 extends Exception, E2 extends Exception> { |
| |
| /** |
| * Called for each symlink in the fileset's output tree. |
| * |
| * @param name path of the symlink relative to the fileset's root; equivalent to {@link |
| * FilesetOutputSymlink#getName} |
| * @param target symlink target; either an absolute path if the symlink points to a source file |
| * or an execroot-relative path if the symlink points to an output |
| * @param metadata a {@link FileArtifactValue} representing the target's metadata if available, |
| * or {@code null} |
| */ |
| void acceptSymlink(PathFragment name, PathFragment target, @Nullable FileArtifactValue metadata) |
| throws E1, E2; |
| } |
| |
| /** |
| * Visits the symlinks in this fileset tree, handling relative symlinks according to the given |
| * {@link RelativeSymlinkBehavior}. |
| */ |
| public <E1 extends Exception, E2 extends Exception> void visitSymlinks( |
| RelativeSymlinkBehavior relSymlinkBehavior, Visitor<E1, E2> visitor) |
| throws ForbiddenRelativeSymlinkException, E1, E2 { |
| var relSymlinkBehaviorWithoutError = |
| switch (relSymlinkBehavior) { |
| case RESOLVE_FULLY -> RelativeSymlinkBehaviorWithoutError.RESOLVE_FULLY; |
| case RESOLVE -> RelativeSymlinkBehaviorWithoutError.RESOLVE; |
| case IGNORE -> RelativeSymlinkBehaviorWithoutError.IGNORE; |
| case ERROR -> { |
| if (hasRelativeSymlinks) { |
| FilesetOutputSymlink relativeLink = |
| symlinks.stream().filter(FilesetOutputTree::isRelativeSymlink).findFirst().get(); |
| throw new ForbiddenRelativeSymlinkException(relativeLink); |
| } |
| yield RelativeSymlinkBehaviorWithoutError.IGNORE; |
| } |
| }; |
| visitSymlinks(relSymlinkBehaviorWithoutError, visitor); |
| } |
| |
| /** |
| * Visits the symlinks in this fileset tree, handling relative symlinks according to the given |
| * {@link RelativeSymlinkBehaviorWithoutError}. |
| */ |
| public <E1 extends Exception, E2 extends Exception> void visitSymlinks( |
| RelativeSymlinkBehaviorWithoutError relSymlinkBehavior, Visitor<E1, E2> visitor) |
| throws E1, E2 { |
| // Fast path: if we don't need to resolve relative symlinks (either because there are none or |
| // because we are ignoring them), perform a single-pass visitation. This is expected to be the |
| // common case. |
| if (!hasRelativeSymlinks || relSymlinkBehavior == RelativeSymlinkBehaviorWithoutError.IGNORE) { |
| for (FilesetOutputSymlink symlink : symlinks) { |
| if (!isRelativeSymlink(symlink)) { |
| visitor.acceptSymlink( |
| symlink.name(), |
| symlink.targetPath(), |
| symlink.metadata() instanceof FileArtifactValue metadata ? metadata : null); |
| } |
| } |
| return; |
| } |
| |
| // Symlink name to resolved target path. Relative target paths are relative to the exec root. |
| Map<PathFragment, String> resolvedLinks = new LinkedHashMap<>(); |
| // Symlink name to relative target path. |
| Map<PathFragment, String> relativeLinks = new HashMap<>(); |
| // Resolved target path to metadata. |
| Map<String, FileArtifactValue> artifactValues = new HashMap<>(); |
| |
| for (FilesetOutputSymlink outputSymlink : symlinks) { |
| PathFragment name = outputSymlink.name(); |
| String targetPath = outputSymlink.targetPath().getPathString(); |
| var map = isRelativeSymlink(outputSymlink) ? relativeLinks : resolvedLinks; |
| |
| // Symlinks are already deduplicated by name in SkyframeFilesetManifestAction. |
| checkState(map.put(name, targetPath) == null, "Duplicate fileset entry at %s", name); |
| |
| if (outputSymlink.metadata() instanceof FileArtifactValue metadata) { |
| artifactValues.put(targetPath, metadata); |
| } |
| } |
| |
| if (relSymlinkBehavior == RelativeSymlinkBehaviorWithoutError.RESOLVE_FULLY) { |
| fullyResolveRelativeSymlinks(resolvedLinks, relativeLinks); |
| } else { |
| resolveRelativeSymlinks(resolvedLinks, relativeLinks); |
| } |
| |
| for (var entry : resolvedLinks.entrySet()) { |
| PathFragment name = entry.getKey(); |
| String target = entry.getValue(); |
| FileArtifactValue metadata = artifactValues.get(target); |
| visitor.acceptSymlink(name, PathFragment.create(target), metadata); |
| } |
| } |
| |
| public ImmutableList<FilesetOutputSymlink> symlinks() { |
| return symlinks; |
| } |
| |
| public int size() { |
| return symlinks.size(); |
| } |
| |
| public boolean isEmpty() { |
| return symlinks.isEmpty(); |
| } |
| |
| @Override |
| public int hashCode() { |
| return symlinks.hashCode(); |
| } |
| |
| @Override |
| public boolean equals(Object o) { |
| if (this == o) { |
| return true; |
| } |
| if (!(o instanceof FilesetOutputTree that)) { |
| return false; |
| } |
| return symlinks.equals(that.symlinks); |
| } |
| |
| @Override |
| public String toString() { |
| return MoreObjects.toStringHelper(this) |
| .add("symlinks", symlinks) |
| .add("hasRelativeSymlinks", hasRelativeSymlinks) |
| .toString(); |
| } |
| |
| /** Mode that determines how to handle relative target paths. */ |
| public enum RelativeSymlinkBehavior { |
| /** Ignore any relative target paths. */ |
| IGNORE, |
| /** Give an error if a relative target path is encountered. */ |
| ERROR, |
| /** Resolve relative target paths that (transitively) point to another file in the fileset. */ |
| RESOLVE, |
| /** |
| * Fully resolve all relative paths, even those pointing to internal directories. Then do a |
| * virtual filesystem traversal to find all paths to all files in the fileset. |
| */ |
| RESOLVE_FULLY |
| } |
| |
| /** |
| * Shadow of {@link RelativeSymlinkBehavior} without the {@link RelativeSymlinkBehavior#ERROR} |
| * value for callers who know there won't be an error thrown when constructing the manifest. |
| */ |
| public enum RelativeSymlinkBehaviorWithoutError { |
| /** Ignore any relative target paths. */ |
| IGNORE, |
| /** Resolve all relative target paths. */ |
| RESOLVE, |
| /** Fully resolve all relative paths, even those pointing to internal directories. */ |
| RESOLVE_FULLY |
| } |
| |
| private static boolean isRelativeSymlink(FilesetOutputSymlink symlink) { |
| return !symlink.targetPath().isAbsolute() && !symlink.relativeToExecRoot(); |
| } |
| |
| /** Fully resolves relative symlinks, including internal directory symlinks. */ |
| private static void fullyResolveRelativeSymlinks( |
| Map<PathFragment, String> resolvedLinks, Map<PathFragment, String> relativeLinks) { |
| try { |
| // Construct an in-memory Filesystem containing all the non-relative-symlink entries in the |
| // Fileset. Treat these as regular files in the filesystem whose contents are the "real" |
| // symlink pointing out of the Fileset. For relative symlinks, we encode these as symlinks in |
| // the in-memory Filesystem. This allows us to then crawl the filesystem for files. Any |
| // readable file is a valid part of the FilesetManifest. Dangling internal links or symlink |
| // cycles will be discovered by the in-memory filesystem. |
| // (Choice of digest function is irrelevant). |
| InMemoryFileSystem fs = new InMemoryFileSystem(DigestHashFunction.SHA256); |
| Path root = fs.getPath("/"); |
| for (Map.Entry<PathFragment, String> e : resolvedLinks.entrySet()) { |
| PathFragment location = e.getKey(); |
| Path locationPath = root.getRelative(location); |
| locationPath.getParentDirectory().createDirectoryAndParents(); |
| FileSystemUtils.writeContentAsLatin1(locationPath, e.getValue()); |
| } |
| for (Map.Entry<PathFragment, String> e : relativeLinks.entrySet()) { |
| PathFragment location = e.getKey(); |
| Path locationPath = fs.getPath("/").getRelative(location); |
| PathFragment value = PathFragment.create(checkNotNull(e.getValue(), e)); |
| |
| locationPath.getParentDirectory().createDirectoryAndParents(); |
| locationPath.createSymbolicLink(value); |
| } |
| |
| addSymlinks(root, resolvedLinks); |
| } catch (IOException e) { |
| throw new IllegalStateException("InMemoryFileSystem can't throw", e); |
| } |
| } |
| |
| private static void addSymlinks(Path root, Map<PathFragment, String> resolvedLinks) |
| throws IOException { |
| for (Path path : root.getDirectoryEntries()) { |
| try { |
| if (path.isDirectory()) { |
| addSymlinks(path, resolvedLinks); |
| } else { |
| String contents = new String(FileSystemUtils.readContentAsLatin1(path)); |
| resolvedLinks.put(path.asFragment().toRelative(), contents); |
| } |
| } catch (IOException e) { |
| logger.atWarning().log("Symlink %s is dangling or cyclic: %s", path, e.getMessage()); |
| } |
| } |
| } |
| |
| /** Resolves relative symlinks and puts them in the {@code resolvedLinks} map. */ |
| private static void resolveRelativeSymlinks( |
| Map<PathFragment, String> resolvedLinks, Map<PathFragment, String> relativeLinks) { |
| for (Map.Entry<PathFragment, String> e : relativeLinks.entrySet()) { |
| PathFragment location = e.getKey(); |
| String actual = e.getValue(); |
| PathFragment actualLocation = location; |
| |
| // Recursively resolve relative symlinks. |
| LinkedHashSet<String> seen = new LinkedHashSet<>(); |
| int traversals = 0; |
| do { |
| actualLocation = actualLocation.getParentDirectory().getRelative(actual); |
| actual = relativeLinks.get(actualLocation); |
| } while (++traversals <= MAX_SYMLINK_TRAVERSALS && actual != null && seen.add(actual)); |
| |
| if (traversals >= MAX_SYMLINK_TRAVERSALS) { |
| logger.atWarning().log( |
| "Symlink %s is part of a chain of length at least %d" |
| + " which exceeds Blaze's maximum allowable symlink chain length", |
| location, traversals); |
| } else if (actual != null) { |
| // TODO(b/113128395): throw here. |
| logger.atWarning().log("Symlink %s forms a symlink cycle: %s", location, seen); |
| } else { |
| String resolvedTarget = resolvedLinks.get(actualLocation); |
| if (resolvedTarget == null) { |
| // We've found a relative symlink that points out of the fileset. We should really |
| // always throw here, but current behavior is that we tolerate such symlinks when they |
| // occur in runfiles, which is the only time this code is hit. |
| // TODO(b/113128395): throw here. |
| logger.atWarning().log( |
| "Symlink %s (transitively) points to %s that is not in this fileset (or was" |
| + " pruned because of a cycle)", |
| location, actualLocation); |
| } else { |
| // We have successfully resolved the symlink. |
| resolvedLinks.put(location, resolvedTarget); |
| } |
| } |
| } |
| } |
| |
| /** Exception indicating that a relative symlink was encountered but not permitted. */ |
| public static final class ForbiddenRelativeSymlinkException |
| extends ForbiddenActionInputException { |
| private ForbiddenRelativeSymlinkException(FilesetOutputSymlink relativeLink) { |
| super( |
| "Fileset symlink %s -> %s is not absolute" |
| .formatted(relativeLink.name(), relativeLink.targetPath())); |
| } |
| } |
| } |