Bug 1650227: Implement IOUtils move method r=barret,Gijs

This patch introduces a move method to the IOUtils interface, which allows
for renaming/moving files or directories on disk. Source and destination
files may be specified either by an absolute path, or a relative path from
the current working directory.

This method has well-defined behaviour similar to the POSIX mv command
(except that this may create missing directories as necessary).
The behaviour is briefly summarized below:

1. If the source is a file that exists:

 a. If the destination is a file that does not exist, the source is
    renamed (and re-parented as a child of the destination parent
    directory). The destination parent directory will be created if
    necessary.

 b. If the destination is a file that does exist, the destination is
    replaced with the source (unless the noOverwrite option is true).


2. If the source is a directory that exists:

 a. If the destination is a directory, then the source directory is
    re-parented such that it becomes a child of the destination.

 b. If the destination does not exist, then the source is renamed,
    creating additional directories if needed.

 c. If the destination is a file, then an error occurs.


3. If the source does not exist, an error occurs.

Differential Revision: https://phabricator.services.mozilla.com/D82202
This commit is contained in:
Keefer Rourke 2020-07-15 16:03:52 +00:00
Родитель 16439a0ec2
Коммит 0dcdd65989
5 изменённых файлов: 450 добавлений и 10 удалений

Просмотреть файл

@ -25,6 +25,17 @@ namespace IOUtils {
* @param data Data to write to the file at path.
*/
Promise<unsigned long long> writeAtomic(DOMString path, Uint8Array data, optional WriteAtomicOptions options = {});
/**
* Moves the file from |sourcePath| to |destPath|, creating necessary parents.
* If |destPath| is a directory, then the source file will be moved into the
* destination directory.
*
* @param sourcePath An absolute file path identifying the file or directory
* to move.
* @param destPath An absolute file path identifying the destination
* directory and/or file name.
*/
Promise<void> move(DOMString sourcePath, DOMString destPath, optional MoveOptions options = {});
};
/**
@ -52,3 +63,13 @@ dictionary WriteAtomicOptions {
*/
boolean flush = false;
};
/**
* Options to be passed to the |IOUtils.move| method.
*/
dictionary MoveOptions {
/**
* If true, fail if the destination already exists.
*/
boolean noOverwrite = false;
};

Просмотреть файл

@ -8,15 +8,21 @@
#include "mozilla/dom/IOUtils.h"
#include "mozilla/dom/Promise.h"
#include "mozilla/ErrorNames.h"
#include "mozilla/Result.h"
#include "mozilla/ResultExtensions.h"
#include "mozilla/Services.h"
#include "mozilla/Span.h"
#include "nspr/prio.h"
#include "nspr/private/pprio.h"
#include "nspr/prtypes.h"
#include "nsDirectoryServiceDefs.h"
#include "nsIFile.h"
#include "nsIGlobalObject.h"
#include "nsReadableUtils.h"
#include "nsString.h"
#include "nsThreadManager.h"
#include "SpecialSystemDirectory.h"
#if defined(__unix__) || (defined(__APPLE__) && defined(__MACH__))
# include <fcntl.h>
@ -45,6 +51,42 @@
namespace mozilla {
namespace dom {
// static helper functions
/**
* Platform-specific (e.g. Windows, Unix) implementations of XPCOM APIs may
* report I/O errors inconsistently. For convenience, this function will attempt
* to match a |nsresult| against known results which imply a file cannot be
* found.
*
* @see nsLocalFileWin.cpp
* @see nsLocalFileUnix.cpp
*/
static bool IsFileNotFound(nsresult aResult) {
switch (aResult) {
case NS_ERROR_FILE_NOT_FOUND:
case NS_ERROR_FILE_TARGET_DOES_NOT_EXIST:
return true;
default:
return false;
}
}
static nsCString FormatErrorMessage(nsresult aError,
const char* const aMessage) {
const char* errName = GetStaticErrorName(aError);
if (errName) {
return nsPrintfCString("%s: %s", aMessage, errName);
}
// In the exceptional case where there is no error name, print the literal
// integer value of the nsresult as an upper case hex value so it can be
// located easily in searchfox.
return nsPrintfCString("%s: 0x%" PRIX32, aMessage,
static_cast<uint32_t>(aError));
}
// IOUtils implementation
/* static */
StaticDataMutex<StaticRefPtr<nsISerialEventTarget>>
IOUtils::sBackgroundEventTarget("sBackgroundEventTarget");
@ -61,6 +103,9 @@ already_AddRefed<Promise> IOUtils::Read(GlobalObject& aGlobal,
NS_ENSURE_TRUE(!!promise, nullptr);
REJECT_IF_SHUTTING_DOWN(promise);
RefPtr<nsISerialEventTarget> bg = GetBackgroundEventTarget();
REJECT_IF_NULL_EVENT_TARGET(bg, promise);
// Process arguments.
uint32_t toRead = 0;
if (aMaxBytes.WasPassed()) {
@ -76,10 +121,6 @@ already_AddRefed<Promise> IOUtils::Read(GlobalObject& aGlobal,
NS_ConvertUTF16toUTF8 path(aPath);
// Do the IO on a background thread and return the result to this thread.
RefPtr<nsISerialEventTarget> bg = GetBackgroundEventTarget();
REJECT_IF_NULL_EVENT_TARGET(bg, promise);
InvokeAsync(
bg, __func__,
[path, toRead]() {
@ -151,6 +192,9 @@ already_AddRefed<Promise> IOUtils::WriteAtomic(
NS_ENSURE_TRUE(!!promise, nullptr);
REJECT_IF_SHUTTING_DOWN(promise);
RefPtr<nsISerialEventTarget> bg = GetBackgroundEventTarget();
REJECT_IF_NULL_EVENT_TARGET(bg, promise);
// Process arguments.
aData.ComputeState();
FallibleTArray<uint8_t> toWrite;
@ -179,10 +223,6 @@ already_AddRefed<Promise> IOUtils::WriteAtomic(
NS_ConvertUTF16toUTF8 path(aPath);
// Do the IO on a background thread and return the result to this thread.
RefPtr<nsISerialEventTarget> bg = GetBackgroundEventTarget();
REJECT_IF_NULL_EVENT_TARGET(bg, promise);
InvokeAsync(bg, __func__,
[path, flags, noOverwrite, toWrite = std::move(toWrite)]() {
MOZ_ASSERT(!NS_IsMainThread());
@ -227,6 +267,80 @@ already_AddRefed<Promise> IOUtils::WriteAtomic(
return promise.forget();
}
/* static */
already_AddRefed<Promise> IOUtils::Move(GlobalObject& aGlobal,
const nsAString& aSourcePath,
const nsAString& aDestPath,
const MoveOptions& aOptions) {
RefPtr<Promise> promise = CreateJSPromise(aGlobal);
NS_ENSURE_TRUE(!!promise, nullptr);
REJECT_IF_SHUTTING_DOWN(promise);
RefPtr<nsISerialEventTarget> bg = GetBackgroundEventTarget();
REJECT_IF_NULL_EVENT_TARGET(bg, promise);
// Process arguments.
bool noOverwrite = false;
if (aOptions.IsAnyMemberPresent()) {
noOverwrite = aOptions.mNoOverwrite;
}
InvokeAsync(bg, __func__,
[srcPathString = nsAutoString(aSourcePath),
destPathString = nsAutoString(aDestPath), noOverwrite]() {
nsresult rv =
MoveSync(srcPathString, destPathString, noOverwrite);
if (NS_FAILED(rv)) {
return IOMoveMozPromise::CreateAndReject(rv, __func__);
}
return IOMoveMozPromise::CreateAndResolve(true, __func__);
})
->Then(
GetCurrentSerialEventTarget(), __func__,
[promise = RefPtr(promise)](const bool&) {
AutoJSAPI jsapi;
if (NS_WARN_IF(!jsapi.Init(promise->GetGlobalObject()))) {
promise->MaybeReject(NS_ERROR_UNEXPECTED);
return;
}
promise->MaybeResolveWithUndefined();
},
[promise = RefPtr(promise)](const nsresult& aError) {
switch (aError) {
case NS_ERROR_FILE_ACCESS_DENIED:
promise->MaybeRejectWithInvalidAccessError("Access denied");
break;
case NS_ERROR_FILE_TARGET_DOES_NOT_EXIST:
case NS_ERROR_FILE_NOT_FOUND:
promise->MaybeRejectWithNotFoundError(
"Source file does not exist");
break;
case NS_ERROR_FILE_ALREADY_EXISTS:
promise->MaybeRejectWithNoModificationAllowedError(
"Destination file exists and overwrites are not allowed");
break;
case NS_ERROR_FILE_READ_ONLY:
promise->MaybeRejectWithNoModificationAllowedError(
"Destination is read only");
break;
case NS_ERROR_FILE_DESTINATION_NOT_DIR:
promise->MaybeRejectWithInvalidAccessError(
"Source is a directory but destination is not");
break;
case NS_ERROR_FILE_UNRECOGNIZED_PATH:
promise->MaybeRejectWithOperationError(
"Only absolute file paths are permitted");
break;
default: {
promise->MaybeRejectWithUnknownError(
FormatErrorMessage(aError, "Unexpected error moving file"));
}
}
});
return promise.forget();
}
/* static */
already_AddRefed<nsISerialEventTarget> IOUtils::GetBackgroundEventTarget() {
if (sShutdownStarted) {
@ -387,6 +501,68 @@ nsresult IOUtils::WriteSync(PRFileDesc* aFd, const nsTArray<uint8_t>& aBytes,
return NS_OK;
}
/* static */
nsresult IOUtils::MoveSync(const nsAString& aSourcePath,
const nsAString& aDestPath, bool noOverwrite) {
MOZ_ASSERT(!NS_IsMainThread());
nsresult rv = NS_OK;
nsCOMPtr<nsIFile> srcFile = new nsLocalFile();
MOZ_TRY(srcFile->InitWithPath(aSourcePath)); // Fails if not absolute.
MOZ_TRY(srcFile->Normalize()); // Fails if path does not exist.
nsCOMPtr<nsIFile> destFile = new nsLocalFile();
MOZ_TRY(destFile->InitWithPath(aDestPath));
rv = destFile->Normalize();
// Normalize can fail for a number of reasons, including if the file doesn't
// exist. It is expected that the file might not exist for a number of calls
// (e.g. if we want to rename a file to a new location).
if (!IsFileNotFound(rv)) { // Deliberately ignore "not found" errors.
NS_ENSURE_SUCCESS(rv, rv);
}
// Case 1: Destination is an existing directory. Move source into dest.
bool destIsDir = false;
bool destExists = true;
rv = destFile->IsDirectory(&destIsDir);
if (NS_SUCCEEDED(rv) && destIsDir) {
return srcFile->MoveTo(destFile, EmptyString());
}
if (IsFileNotFound(rv)) {
// It's ok if the file doesn't exist. Case 2 handles this below.
destExists = false;
} else {
// Bail out early for any other kind of error though.
NS_ENSURE_SUCCESS(rv, rv);
}
// Case 2: Destination is a file which may or may not exist. Try to rename the
// source to the destination. This will fail if the source is a not a
// regular file.
if (noOverwrite && destExists) {
return NS_ERROR_FILE_ALREADY_EXISTS;
}
if (destExists && !destIsDir) {
// If the source file is a directory, but the target is a file, abort early.
// Different implementations of |MoveTo| seem to handle this error case
// differently (or not at all), so we explicitly handle it here.
bool srcIsDir = false;
MOZ_TRY(srcFile->IsDirectory(&srcIsDir));
if (srcIsDir) {
return NS_ERROR_FILE_DESTINATION_NOT_DIR;
}
}
nsCOMPtr<nsIFile> destDir;
nsAutoString destName;
MOZ_TRY(destFile->GetLeafName(destName));
MOZ_TRY(destFile->GetParent(getter_AddRefs(destDir)));
// NB: if destDir doesn't exist, then MoveTo will create it.
return srcFile->MoveTo(destDir, destName);
}
NS_IMPL_ISUPPORTS(IOUtilsShutdownBlocker, nsIAsyncShutdownBlocker);
NS_IMETHODIMP IOUtilsShutdownBlocker::GetName(nsAString& aName) {

Просмотреть файл

@ -17,6 +17,7 @@
#include "nspr/prio.h"
#include "nsIAsyncShutdown.h"
#include "nsISerialEventTarget.h"
#include "nsLocalFile.h"
namespace mozilla {
@ -51,6 +52,11 @@ class IOUtils final {
GlobalObject& aGlobal, const nsAString& aPath, const Uint8Array& aData,
const WriteAtomicOptions& aOptions);
static already_AddRefed<Promise> Move(GlobalObject& aGlobal,
const nsAString& aSourcePath,
const nsAString& aDestPath,
const MoveOptions& aOptions);
private:
~IOUtils() = default;
@ -96,12 +102,19 @@ class IOUtils final {
static nsresult WriteSync(PRFileDesc* aFd, const nsTArray<uint8_t>& aBytes,
uint32_t& aResult);
static nsresult MoveSync(const nsAString& aSource, const nsAString& aDest,
bool noOverwrite);
using IOReadMozPromise =
mozilla::MozPromise<nsTArray<uint8_t>, const nsCString,
/* IsExclusive */ true>;
using IOWriteMozPromise =
mozilla::MozPromise<uint32_t, const nsCString, /* IsExclusive */ true>;
using IOMoveMozPromise =
mozilla::MozPromise<bool /* ignored */, const nsresult,
/* IsExclusive */ true>;
};
class IOUtilsShutdownBlocker : public nsIAsyncShutdownBlocker {

Просмотреть файл

@ -14,11 +14,14 @@ importScripts("resource://gre/modules/ObjectUtils.jsm");
importScripts("resource://gre/modules/osfile.jsm");
self.onmessage = async function(msg) {
const tmpDir = OS.Constants.Path.tmpDir;
// IOUtils functionality is the same when called from the main thread, or a
// web worker. These tests are a modified subset of the main thread tests, and
// serve as a sanity check that the implementation is thread-safe.
// serve as a confidence check that the implementation is thread-safe.
await test_api_is_available_on_worker();
await test_full_read_and_write();
await test_move_file();
finish();
info("test_ioutils_worker.xhtml: Test finished");
@ -28,6 +31,7 @@ self.onmessage = async function(msg) {
}
async function test_full_read_and_write() {
// TODO: Stop using relative paths.
// Write a file.
const tmpFileName = "test_ioutils_numbers.tmp";
const bytes = Uint8Array.of(...new Array(50).keys());
@ -56,6 +60,21 @@ self.onmessage = async function(msg) {
cleanup(tmpFileName);
}
async function test_move_file() {
const src = OS.Path.join(tmpDir, "test_move_file_src.tmp");
const dest = OS.Path.join(tmpDir, "test_move_file_dest.tmp");
const bytes = Uint8Array.of(...new Array(50).keys());
await self.IOUtils.writeAtomic(src, bytes);
await self.IOUtils.move(src, dest);
ok(
!OS.File.exists(src) && OS.File.exists(dest),
"IOUtils::move can move files from a worker"
);
cleanup(dest);
}
};
function cleanup(...files) {

Просмотреть файл

@ -24,11 +24,14 @@
const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm");
const tmpDir = OS.Constants.Path.tmpDir;
add_task(async function test_api_is_available_on_window() {
ok(window.IOUtils, "IOUtils is present on the window");
});
add_task(async function test_read_failure() {
// TODO: Stop using relative paths.
await Assert.rejects(
window.IOUtils.read("does_not_exist.txt"),
/Could not open file/,
@ -37,6 +40,7 @@
});
add_task(async function test_write_no_overwrite() {
// TODO: Stop using relative paths.
// Make a new file, and try to write to it with overwrites disabled.
const tmpFileName = "test_ioutils_overwrite.tmp";
const untouchableContents = new TextEncoder().encode("Can't touch this!\n");
@ -66,6 +70,7 @@
});
add_task(async function test_partial_read() {
// TODO: Stop using relative paths.
const tmpFileName = "test_ioutils_partial_read.tmp";
const bytes = Uint8Array.of(...new Array(50).keys());
const bytesWritten = await window.IOUtils.writeAtomic(tmpFileName, bytes);
@ -92,6 +97,7 @@
});
add_task(async function test_empty_read_and_write() {
// TODO: Stop using relative paths.
// Trying to write an empty file isn't very useful, but it should still
// succeed.
const tmpFileName = "test_ioutils_empty.tmp";
@ -115,7 +121,9 @@
});
add_task(async function test_full_read_and_write() {
// TODO: Stop using relative paths.
// Write a file.
info("Test writing to a new binary file");
const tmpFileName = "test_ioutils_numbers.tmp";
const bytes = Uint8Array.of(...new Array(50).keys());
const bytesWritten = await window.IOUtils.writeAtomic(tmpFileName, bytes);
@ -126,6 +134,7 @@
);
// Read it back.
info("Test reading a binary file");
let fileContents = await window.IOUtils.read(tmpFileName);
ok(
ObjectUtils.deepEqual(bytes, fileContents) &&
@ -141,18 +150,220 @@
"IOUtils::read can read entire file when requested maxBytes is too large"
);
// Clean up.
await cleanup(tmpFileName);
});
add_task(async function test_move_relative_path() {
const tmpFileName = OS.Path.join(tmpDir, "test_ioutils_move_relative_path.tmp");
const dest = "relative_to_cwd.tmp";
await createFile(tmpFileName, "source");
info("Test moving a file to a relative destination");
await Assert.rejects(
window.IOUtils.move(tmpFileName, dest),
/Only absolute file paths are permitted/,
"IOUtils::move only works with absolute paths"
);
ok(
await fileHasTextContents(tmpFileName, "source"),
"IOUtils::move doesn't change source file when move fails"
);
cleanup(tmpFileName);
});
add_task(async function test_move_rename() {
// Set up.
const tmpFileName = OS.Path.join(tmpDir, "test_ioutils_move_src.tmp");
const destFileName = OS.Path.join(tmpDir, "test_ioutils_move_dest.tmp");
await createFile(tmpFileName, "dest");
// Test.
info("Test move to new file in same directory");
await window.IOUtils.move(tmpFileName, destFileName);
info(`moved ${tmpFileName} to ${destFileName}`);
ok(
!await fileExists(tmpFileName)
&& await fileHasTextContents(destFileName, "dest"),
"IOUtils::move can move source to dest in same directory"
)
// Set up.
info("Test move to existing file with no overwrite");
await createFile(tmpFileName, "source");
// Test.
await Assert.rejects(
window.IOUtils.move(tmpFileName, destFileName, { noOverwrite: true }),
/Destination file exists and overwrites are not allowed/,
"IOUtils::move will refuse to move a file if overwrites are disabled"
);
ok(
await fileExists(tmpFileName)
&& await fileHasTextContents(destFileName, "dest"),
"Failed IOUtils::move doesn't move the source file"
);
// Test.
info("Test move to existing file with overwrite");
await window.IOUtils.move(tmpFileName, destFileName, { noOverwrite: false });
ok(!await fileExists(tmpFileName), "IOUtils::move moved source");
ok(
await fileHasTextContents(destFileName, "source"),
"IOUtils::move overwrote the destination with the source"
);
// Clean up.
await cleanup(tmpFileName, destFileName);
});
add_task(async function test_move_to_dir() {
// Set up.
info("Test move and rename to non-existing directory");
const tmpFileName = OS.Path.join(tmpDir, "test_move_to_dir.tmp");
const destDir = OS.Path.join(tmpDir, "test_move_to_dir.tmp.d");
const dest = OS.Path.join(destDir, "dest.tmp");
await createFile(tmpFileName);
// Test.
ok(!await OS.File.exists(destDir), "Expected path not to exist");
await window.IOUtils.move(tmpFileName, dest);
ok(
!await fileExists(tmpFileName) && await fileExists(dest),
"IOUtils::move creates non-existing parents if needed"
);
// Set up.
info("Test move and rename to existing directory.")
await createFile(tmpFileName);
// Test.
ok(await OS.File.exists(destDir), "Expected path to exist");
await window.IOUtils.move(tmpFileName, dest);
ok(
!await fileExists(tmpFileName)
&& await fileExists(dest),
"IOUtils::move can move/rename a file into an existing dir"
);
// Set up.
info("Test move to existing directory without specifying leaf name.")
await createFile(tmpFileName);
// Test.
await window.IOUtils.move(tmpFileName, destDir);
ok(await OS.File.exists(destDir), "Expected path to exist");
ok(
!await fileExists(tmpFileName)
&& await fileExists(OS.Path.join(destDir, OS.Path.basename(tmpFileName))),
"IOUtils::move can move a file into an existing dir"
);
// Clean up.
await OS.File.removeDir(destDir);
});
add_task(async function test_move_dir() {
// Set up.
info("Test rename an empty directory");
const srcDir = OS.Path.join(tmpDir, "test_move_dir.tmp.d");
const destDir = OS.Path.join(tmpDir, "test_move_dir_dest.tmp.d");
await OS.File.makeDir(srcDir);
// Test.
await window.IOUtils.move(srcDir, destDir);
ok(
!await OS.File.exists(srcDir) && await OS.File.exists(destDir),
"IOUtils::move can rename directories"
);
// Set up.
info("Test move directory and its content into another directory");
await OS.File.makeDir(srcDir);
await createFile(OS.Path.join(srcDir, "file.tmp"), "foo");
// Test.
await window.IOUtils.move(srcDir, destDir);
const destFile = OS.Path.join(destDir, OS.Path.basename(srcDir), "file.tmp");
ok(
!await OS.File.exists(srcDir)
&& await OS.File.exists(destDir)
&& await OS.File.exists(OS.Path.join(destDir, OS.Path.basename(srcDir)))
&& await fileHasTextContents(destFile, "foo"),
"IOUtils::move can move a directory and its contents into another one"
)
// Clean up.
await OS.File.removeDir(destDir);
});
add_task(async function test_move_failures() {
// Set up.
info("Test attempt to rename a non-existent source file");
const notExistsSrc = OS.Path.join(tmpDir, "not_exists_src.tmp");
const notExistsDest = OS.Path.join(tmpDir, "not_exists_dest.tmp");
// Test.
await Assert.rejects(
window.IOUtils.move(notExistsSrc, notExistsDest),
/Source file does not exist/,
"IOUtils::move throws if source file does not exist"
);
ok(
!await fileExists(notExistsSrc) && !await fileExists(notExistsDest),
"IOUtils::move fails if source file does not exist"
);
// Set up.
info("Test attempt to move a directory to a file");
const destFile = OS.Path.join(tmpDir, "test_move_failures_file_dest.tmp");
const srcDir = OS.Path.join(tmpDir, "test_move_failure_src.tmp.d");
await createFile(destFile);
await OS.File.makeDir(srcDir);
// Test.
await Assert.rejects(
window.IOUtils.move(srcDir, destFile),
/Source is a directory but destination is not/,
"IOUtils::move throws if try to move dir into an existing file"
);
// Clean up.
await cleanup(destFile);
await OS.File.removeDir(srcDir);
});
// Utility functions.
async function createFile(location, contents = "") {
if (typeof contents === "string") {
contents = new TextEncoder().encode(contents);
}
await window.IOUtils.writeAtomic(location, contents);
const exists = await fileExists(location);
ok(exists, `Created temporary file at: ${location}`);
}
async function fileHasTextContents(location, expectedContents) {
if (typeof expectedContents !== "string") {
throw new TypeError("expectedContents must be a string");
}
info(`opening ${location} for reading`);
const bytes = await window.IOUtils.read(location);
const contents = new TextDecoder().decode(bytes);
return contents === expectedContents;
}
async function fileExists(file) {
try {
await window.IOUtils.read(file);
} catch (ex) {
return false;
}
return true;
}
async function cleanup(...files) {
for (const file of files) {
await OS.File.remove(file);
const exists = await OS.File.exists(file);
const exists = await fileExists(file);
ok(!exists, `Removed temporary file: ${file}`);
}
}
</script>
</head>