blob: 75027160767a84bcf0181ad411ff4282f33b6ba1 [file] [log] [blame]
// 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()));
}
}
}