blob: 62e8739c8a48c5ae55710344042be7f4ba69e261 [file] [log] [blame]
/*
* Copyright 2013 Google Inc.
*
* 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.common.jimfs;
import static com.google.common.base.Preconditions.checkNotNull;
import static java.nio.file.StandardCopyOption.COPY_ATTRIBUTES;
import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
import static java.nio.file.StandardOpenOption.CREATE;
import static java.nio.file.StandardOpenOption.CREATE_NEW;
import static java.nio.file.StandardOpenOption.TRUNCATE_EXISTING;
import static java.nio.file.StandardOpenOption.WRITE;
import com.google.common.base.Supplier;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSortedSet;
import com.google.common.collect.Lists;
import java.io.IOException;
import java.nio.file.CopyOption;
import java.nio.file.DirectoryNotEmptyException;
import java.nio.file.DirectoryStream;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.FileSystemException;
import java.nio.file.LinkOption;
import java.nio.file.NoSuchFileException;
import java.nio.file.OpenOption;
import java.nio.file.Path;
import java.nio.file.SecureDirectoryStream;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileAttribute;
import java.nio.file.attribute.FileAttributeView;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import org.checkerframework.checker.nullness.compatqual.NullableDecl;
/**
* View of a file system with a specific working directory. As all file system operations need to
* work when given either relative or absolute paths, this class contains the implementation of most
* file system operations, with relative path operations resolving against the working directory.
*
* <p>A file system has one default view using the file system's working directory. Additional views
* may be created for use in {@link SecureDirectoryStream} instances, which each have a different
* working directory they use.
*
* @author Colin Decker
*/
final class FileSystemView {
private final JimfsFileStore store;
private final Directory workingDirectory;
private final JimfsPath workingDirectoryPath;
/** Creates a new file system view. */
public FileSystemView(
JimfsFileStore store, Directory workingDirectory, JimfsPath workingDirectoryPath) {
this.store = checkNotNull(store);
this.workingDirectory = checkNotNull(workingDirectory);
this.workingDirectoryPath = checkNotNull(workingDirectoryPath);
}
/** Returns whether or not this view and the given view belong to the same file system. */
private boolean isSameFileSystem(FileSystemView other) {
return store == other.store;
}
/** Returns the file system state. */
public FileSystemState state() {
return store.state();
}
/**
* Returns the path of the working directory at the time this view was created. Does not reflect
* changes to the path caused by the directory being moved.
*/
public JimfsPath getWorkingDirectoryPath() {
return workingDirectoryPath;
}
/** Attempt to look up the file at the given path. */
DirectoryEntry lookUpWithLock(JimfsPath path, Set<? super LinkOption> options)
throws IOException {
store.readLock().lock();
try {
return lookUp(path, options);
} finally {
store.readLock().unlock();
}
}
/** Looks up the file at the given path without locking. */
private DirectoryEntry lookUp(JimfsPath path, Set<? super LinkOption> options)
throws IOException {
return store.lookUp(workingDirectory, path, options);
}
/**
* Creates a new directory stream for the directory located by the given path. The given {@code
* basePathForStream} is that base path that the returned stream will use. This will be the same
* as {@code dir} except for streams created relative to another secure stream.
*/
public DirectoryStream<Path> newDirectoryStream(
JimfsPath dir,
DirectoryStream.Filter<? super Path> filter,
Set<? super LinkOption> options,
JimfsPath basePathForStream)
throws IOException {
Directory file = (Directory) lookUpWithLock(dir, options).requireDirectory(dir).file();
FileSystemView view = new FileSystemView(store, file, basePathForStream);
JimfsSecureDirectoryStream stream = new JimfsSecureDirectoryStream(view, filter, state());
return store.supportsFeature(Feature.SECURE_DIRECTORY_STREAM)
? stream
: new DowngradedDirectoryStream(stream);
}
/** Snapshots the entries of the working directory of this view. */
public ImmutableSortedSet<Name> snapshotWorkingDirectoryEntries() {
store.readLock().lock();
try {
ImmutableSortedSet<Name> names = workingDirectory.snapshot();
workingDirectory.updateAccessTime();
return names;
} finally {
store.readLock().unlock();
}
}
/**
* Returns a snapshot mapping the names of each file in the directory at the given path to the
* last modified time of that file.
*/
public ImmutableMap<Name, Long> snapshotModifiedTimes(JimfsPath path) throws IOException {
ImmutableMap.Builder<Name, Long> modifiedTimes = ImmutableMap.builder();
store.readLock().lock();
try {
Directory dir = (Directory) lookUp(path, Options.FOLLOW_LINKS).requireDirectory(path).file();
// TODO(cgdecker): Investigate whether WatchServices should keep a reference to the actual
// directory when SecureDirectoryStream is supported rather than looking up the directory
// each time the WatchService polls
for (DirectoryEntry entry : dir) {
if (!entry.name().equals(Name.SELF) && !entry.name().equals(Name.PARENT)) {
modifiedTimes.put(entry.name(), entry.file().getLastModifiedTime());
}
}
return modifiedTimes.build();
} finally {
store.readLock().unlock();
}
}
/**
* Returns whether or not the two given paths locate the same file. The second path is located
* using the given view rather than this file view.
*/
public boolean isSameFile(JimfsPath path, FileSystemView view2, JimfsPath path2)
throws IOException {
if (!isSameFileSystem(view2)) {
return false;
}
store.readLock().lock();
try {
File file = lookUp(path, Options.FOLLOW_LINKS).fileOrNull();
File file2 = view2.lookUp(path2, Options.FOLLOW_LINKS).fileOrNull();
return file != null && Objects.equals(file, file2);
} finally {
store.readLock().unlock();
}
}
/**
* Gets the {@linkplain Path#toRealPath(LinkOption...) real path} to the file located by the given
* path.
*/
public JimfsPath toRealPath(
JimfsPath path, PathService pathService, Set<? super LinkOption> options) throws IOException {
checkNotNull(path);
checkNotNull(options);
store.readLock().lock();
try {
DirectoryEntry entry = lookUp(path, options).requireExists(path);
List<Name> names = new ArrayList<>();
names.add(entry.name());
while (!entry.file().isRootDirectory()) {
entry = entry.directory().entryInParent();
names.add(entry.name());
}
// names are ordered last to first in the list, so get the reverse view
List<Name> reversed = Lists.reverse(names);
Name root = reversed.remove(0);
return pathService.createPath(root, reversed);
} finally {
store.readLock().unlock();
}
}
/**
* Creates a new directory at the given path. The given attributes will be set on the new file if
* possible.
*/
public Directory createDirectory(JimfsPath path, FileAttribute<?>... attrs) throws IOException {
return (Directory) createFile(path, store.directoryCreator(), true, attrs);
}
/**
* Creates a new symbolic link at the given path with the given target. The given attributes will
* be set on the new file if possible.
*/
public SymbolicLink createSymbolicLink(
JimfsPath path, JimfsPath target, FileAttribute<?>... attrs) throws IOException {
if (!store.supportsFeature(Feature.SYMBOLIC_LINKS)) {
throw new UnsupportedOperationException();
}
return (SymbolicLink) createFile(path, store.symbolicLinkCreator(target), true, attrs);
}
/**
* Creates a new file at the given path if possible, using the given supplier to create the file.
* Returns the new file. If {@code allowExisting} is {@code true} and a file already exists at the
* given path, returns that file. Otherwise, throws {@link FileAlreadyExistsException}.
*/
private File createFile(
JimfsPath path,
Supplier<? extends File> fileCreator,
boolean failIfExists,
FileAttribute<?>... attrs)
throws IOException {
checkNotNull(path);
checkNotNull(fileCreator);
store.writeLock().lock();
try {
DirectoryEntry entry = lookUp(path, Options.NOFOLLOW_LINKS);
if (entry.exists()) {
if (failIfExists) {
throw new FileAlreadyExistsException(path.toString());
}
// currently can only happen if getOrCreateFile doesn't find the file with the read lock
// and then the file is created between when it releases the read lock and when it
// acquires the write lock; so, very unlikely
return entry.file();
}
Directory parent = entry.directory();
File newFile = fileCreator.get();
store.setInitialAttributes(newFile, attrs);
parent.link(path.name(), newFile);
parent.updateModifiedTime();
return newFile;
} finally {
store.writeLock().unlock();
}
}
/**
* Gets the regular file at the given path, creating it if it doesn't exist and the given options
* specify that it should be created.
*/
public RegularFile getOrCreateRegularFile(
JimfsPath path, Set<OpenOption> options, FileAttribute<?>... attrs) throws IOException {
checkNotNull(path);
if (!options.contains(CREATE_NEW)) {
// assume file exists unless we're explicitly trying to create a new file
RegularFile file = lookUpRegularFile(path, options);
if (file != null) {
return file;
}
}
if (options.contains(CREATE) || options.contains(CREATE_NEW)) {
return getOrCreateRegularFileWithWriteLock(path, options, attrs);
} else {
throw new NoSuchFileException(path.toString());
}
}
/**
* Looks up the regular file at the given path, throwing an exception if the file isn't a regular
* file. Returns null if the file did not exist.
*/
@NullableDecl
private RegularFile lookUpRegularFile(JimfsPath path, Set<OpenOption> options)
throws IOException {
store.readLock().lock();
try {
DirectoryEntry entry = lookUp(path, options);
if (entry.exists()) {
File file = entry.file();
if (!file.isRegularFile()) {
throw new FileSystemException(path.toString(), null, "not a regular file");
}
return open((RegularFile) file, options);
} else {
return null;
}
} finally {
store.readLock().unlock();
}
}
/** Gets or creates a new regular file with a write lock (assuming the file does not exist). */
private RegularFile getOrCreateRegularFileWithWriteLock(
JimfsPath path, Set<OpenOption> options, FileAttribute<?>[] attrs) throws IOException {
store.writeLock().lock();
try {
File file = createFile(path, store.regularFileCreator(), options.contains(CREATE_NEW), attrs);
// the file already existed but was not a regular file
if (!file.isRegularFile()) {
throw new FileSystemException(path.toString(), null, "not a regular file");
}
return open((RegularFile) file, options);
} finally {
store.writeLock().unlock();
}
}
/**
* Opens the given regular file with the given options, truncating it if necessary and
* incrementing its open count. Returns the given file.
*/
private static RegularFile open(RegularFile file, Set<OpenOption> options) {
if (options.contains(TRUNCATE_EXISTING) && options.contains(WRITE)) {
file.writeLock().lock();
try {
file.truncate(0);
} finally {
file.writeLock().unlock();
}
}
// must be opened while holding a file store lock to ensure no race between opening and
// deleting the file
file.opened();
return file;
}
/** Returns the target of the symbolic link at the given path. */
public JimfsPath readSymbolicLink(JimfsPath path) throws IOException {
if (!store.supportsFeature(Feature.SYMBOLIC_LINKS)) {
throw new UnsupportedOperationException();
}
SymbolicLink symbolicLink =
(SymbolicLink)
lookUpWithLock(path, Options.NOFOLLOW_LINKS).requireSymbolicLink(path).file();
return symbolicLink.target();
}
/**
* Checks access to the file at the given path for the given modes. Since access controls are not
* implemented for this file system, this just checks that the file exists.
*/
public void checkAccess(JimfsPath path) throws IOException {
// just check that the file exists
lookUpWithLock(path, Options.FOLLOW_LINKS).requireExists(path);
}
/**
* Creates a hard link at the given link path to the regular file at the given path. The existing
* file must exist and must be a regular file. The given file system view must belong to the same
* file system as this view.
*/
public void link(JimfsPath link, FileSystemView existingView, JimfsPath existing)
throws IOException {
checkNotNull(link);
checkNotNull(existingView);
checkNotNull(existing);
if (!store.supportsFeature(Feature.LINKS)) {
throw new UnsupportedOperationException();
}
if (!isSameFileSystem(existingView)) {
throw new FileSystemException(
link.toString(),
existing.toString(),
"can't link: source and target are in different file system instances");
}
Name linkName = link.name();
// existingView is in the same file system, so just one lock is needed
store.writeLock().lock();
try {
// we do want to follow links when finding the existing file
File existingFile =
existingView.lookUp(existing, Options.FOLLOW_LINKS).requireExists(existing).file();
if (!existingFile.isRegularFile()) {
throw new FileSystemException(
link.toString(), existing.toString(), "can't link: not a regular file");
}
Directory linkParent =
lookUp(link, Options.NOFOLLOW_LINKS).requireDoesNotExist(link).directory();
linkParent.link(linkName, existingFile);
linkParent.updateModifiedTime();
} finally {
store.writeLock().unlock();
}
}
/** Deletes the file at the given absolute path. */
public void deleteFile(JimfsPath path, DeleteMode deleteMode) throws IOException {
store.writeLock().lock();
try {
DirectoryEntry entry = lookUp(path, Options.NOFOLLOW_LINKS).requireExists(path);
delete(entry, deleteMode, path);
} finally {
store.writeLock().unlock();
}
}
/** Deletes the given directory entry from its parent directory. */
private void delete(DirectoryEntry entry, DeleteMode deleteMode, JimfsPath pathForException)
throws IOException {
Directory parent = entry.directory();
File file = entry.file();
checkDeletable(file, deleteMode, pathForException);
parent.unlink(entry.name());
parent.updateModifiedTime();
file.deleted();
}
/** Mode for deleting. Determines what types of files can be deleted. */
public enum DeleteMode {
/** Delete any file. */
ANY,
/** Only delete non-directory files. */
NON_DIRECTORY_ONLY,
/** Only delete directory files. */
DIRECTORY_ONLY
}
/** Checks that the given file can be deleted, throwing an exception if it can't. */
private void checkDeletable(File file, DeleteMode mode, Path path) throws IOException {
if (file.isRootDirectory()) {
throw new FileSystemException(path.toString(), null, "can't delete root directory");
}
if (file.isDirectory()) {
if (mode == DeleteMode.NON_DIRECTORY_ONLY) {
throw new FileSystemException(path.toString(), null, "can't delete: is a directory");
}
checkEmpty(((Directory) file), path);
} else if (mode == DeleteMode.DIRECTORY_ONLY) {
throw new FileSystemException(path.toString(), null, "can't delete: is not a directory");
}
if (file == workingDirectory && !path.isAbsolute()) {
// this is weird, but on Unix at least, the file system seems to be happy to delete the
// working directory if you give the absolute path to it but fail if you use a relative path
// that resolves to the working directory (e.g. "" or ".")
throw new FileSystemException(path.toString(), null, "invalid argument");
}
}
/** Checks that given directory is empty, throwing {@link DirectoryNotEmptyException} if not. */
private void checkEmpty(Directory dir, Path pathForException) throws FileSystemException {
if (!dir.isEmpty()) {
throw new DirectoryNotEmptyException(pathForException.toString());
}
}
/** Copies or moves the file at the given source path to the given dest path. */
public void copy(
JimfsPath source,
FileSystemView destView,
JimfsPath dest,
Set<CopyOption> options,
boolean move)
throws IOException {
checkNotNull(source);
checkNotNull(destView);
checkNotNull(dest);
checkNotNull(options);
boolean sameFileSystem = isSameFileSystem(destView);
File sourceFile;
File copyFile = null; // non-null after block completes iff source file was copied
lockBoth(store.writeLock(), destView.store.writeLock());
try {
DirectoryEntry sourceEntry = lookUp(source, options).requireExists(source);
DirectoryEntry destEntry = destView.lookUp(dest, Options.NOFOLLOW_LINKS);
Directory sourceParent = sourceEntry.directory();
sourceFile = sourceEntry.file();
Directory destParent = destEntry.directory();
if (move && sourceFile.isDirectory()) {
if (sameFileSystem) {
checkMovable(sourceFile, source);
checkNotAncestor(sourceFile, destParent, destView);
} else {
// move to another file system is accomplished by copy-then-delete, so the source file
// must be deletable to be moved
checkDeletable(sourceFile, DeleteMode.ANY, source);
}
}
if (destEntry.exists()) {
if (destEntry.file().equals(sourceFile)) {
return;
} else if (options.contains(REPLACE_EXISTING)) {
destView.delete(destEntry, DeleteMode.ANY, dest);
} else {
throw new FileAlreadyExistsException(dest.toString());
}
}
if (move && sameFileSystem) {
// Real move on the same file system.
sourceParent.unlink(source.name());
sourceParent.updateModifiedTime();
destParent.link(dest.name(), sourceFile);
destParent.updateModifiedTime();
} else {
// Doing a copy OR a move to a different file system, which must be implemented by copy and
// delete.
// By default, don't copy attributes.
AttributeCopyOption attributeCopyOption = AttributeCopyOption.NONE;
if (move) {
// Copy only the basic attributes of the file to the other file system, as it may not
// support all the attribute views that this file system does. This also matches the
// behavior of moving a file to a foreign file system with a different
// FileSystemProvider.
attributeCopyOption = AttributeCopyOption.BASIC;
} else if (options.contains(COPY_ATTRIBUTES)) {
// As with move, if we're copying the file to a different file system, only copy its
// basic attributes.
attributeCopyOption =
sameFileSystem ? AttributeCopyOption.ALL : AttributeCopyOption.BASIC;
}
// Copy the file, but don't copy its content while we're holding the file store locks.
copyFile = destView.store.copyWithoutContent(sourceFile, attributeCopyOption);
destParent.link(dest.name(), copyFile);
destParent.updateModifiedTime();
// In order for the copy to be atomic (not strictly necessary, but seems preferable since
// we can) lock both source and copy files before leaving the file store locks. This
// ensures that users cannot observe the copy's content until the content has been copied.
// This also marks the source file as opened, preventing its content from being deleted
// until after it's copied if the source file itself is deleted in the next step.
lockSourceAndCopy(sourceFile, copyFile);
if (move) {
// It should not be possible for delete to throw an exception here, because we already
// checked that the file was deletable above.
delete(sourceEntry, DeleteMode.ANY, source);
}
}
} finally {
destView.store.writeLock().unlock();
store.writeLock().unlock();
}
if (copyFile != null) {
// Copy the content. This is done outside the above block to minimize the time spent holding
// file store locks, since copying the content of a regular file could take a (relatively)
// long time. If done inside the above block, copying using Files.copy can be slower than
// copying with an InputStream and an OutputStream if many files are being copied on
// different threads.
try {
sourceFile.copyContentTo(copyFile);
} finally {
// Unlock the files, allowing the content of the copy to be observed by the user. This also
// closes the source file, allowing its content to be deleted if it was deleted.
unlockSourceAndCopy(sourceFile, copyFile);
}
}
}
private void checkMovable(File file, JimfsPath path) throws FileSystemException {
if (file.isRootDirectory()) {
throw new FileSystemException(path.toString(), null, "can't move root directory");
}
}
/**
* Acquires both write locks in a way that attempts to avoid the possibility of deadlock. Note
* that typically (when only one file system instance is involved), both locks will be the same
* lock and there will be no issue at all.
*/
private static void lockBoth(Lock sourceWriteLock, Lock destWriteLock) {
while (true) {
sourceWriteLock.lock();
if (destWriteLock.tryLock()) {
return;
} else {
sourceWriteLock.unlock();
}
destWriteLock.lock();
if (sourceWriteLock.tryLock()) {
return;
} else {
destWriteLock.unlock();
}
}
}
/** Checks that source is not an ancestor of dest, throwing an exception if it is. */
private void checkNotAncestor(File source, Directory destParent, FileSystemView destView)
throws IOException {
// if dest is not in the same file system, it couldn't be in source's subdirectories
if (!isSameFileSystem(destView)) {
return;
}
Directory current = destParent;
while (true) {
if (current.equals(source)) {
throw new IOException(
"invalid argument: can't move directory into a subdirectory of itself");
}
if (current.isRootDirectory()) {
return;
} else {
current = current.parent();
}
}
}
/**
* Locks source and copy files before copying content. Also marks the source file as opened so
* that its content won't be deleted until after the copy if it is deleted.
*/
private void lockSourceAndCopy(File sourceFile, File copyFile) {
sourceFile.opened();
ReadWriteLock sourceLock = sourceFile.contentLock();
if (sourceLock != null) {
sourceLock.readLock().lock();
}
ReadWriteLock copyLock = copyFile.contentLock();
if (copyLock != null) {
copyLock.writeLock().lock();
}
}
/**
* Unlocks source and copy files after copying content. Also closes the source file so its content
* can be deleted if it was deleted.
*/
private void unlockSourceAndCopy(File sourceFile, File copyFile) {
ReadWriteLock sourceLock = sourceFile.contentLock();
if (sourceLock != null) {
sourceLock.readLock().unlock();
}
ReadWriteLock copyLock = copyFile.contentLock();
if (copyLock != null) {
copyLock.writeLock().unlock();
}
sourceFile.closed();
}
/** Returns a file attribute view using the given lookup callback. */
@NullableDecl
public <V extends FileAttributeView> V getFileAttributeView(FileLookup lookup, Class<V> type) {
return store.getFileAttributeView(lookup, type);
}
/** Returns a file attribute view for the given path in this view. */
@NullableDecl
public <V extends FileAttributeView> V getFileAttributeView(
final JimfsPath path, Class<V> type, final Set<? super LinkOption> options) {
return store.getFileAttributeView(
new FileLookup() {
@Override
public File lookup() throws IOException {
return lookUpWithLock(path, options).requireExists(path).file();
}
},
type);
}
/** Reads attributes of the file located by the given path in this view as an object. */
public <A extends BasicFileAttributes> A readAttributes(
JimfsPath path, Class<A> type, Set<? super LinkOption> options) throws IOException {
File file = lookUpWithLock(path, options).requireExists(path).file();
return store.readAttributes(file, type);
}
/** Reads attributes of the file located by the given path in this view as a map. */
public ImmutableMap<String, Object> readAttributes(
JimfsPath path, String attributes, Set<? super LinkOption> options) throws IOException {
File file = lookUpWithLock(path, options).requireExists(path).file();
return store.readAttributes(file, attributes);
}
/**
* Sets the given attribute to the given value on the file located by the given path in this view.
*/
public void setAttribute(
JimfsPath path, String attribute, Object value, Set<? super LinkOption> options)
throws IOException {
File file = lookUpWithLock(path, options).requireExists(path).file();
store.setAttribute(file, attribute, value);
}
}