diff --git a/backends/apple/coreai/CMakeLists.txt b/backends/apple/coreai/CMakeLists.txt index 800a9f098a4..bb63606a1a3 100644 --- a/backends/apple/coreai/CMakeLists.txt +++ b/backends/apple/coreai/CMakeLists.txt @@ -10,8 +10,10 @@ endif() enable_language(OBJCXX) -set(_coreai_runtime_sources runtime/coreai_manifest.mm - runtime/coreai_validation.mm +set(_coreai_runtime_sources + runtime/coreai_cache_root.mm runtime/coreai_fault_injection.mm + runtime/coreai_manifest.mm runtime/coreai_validation.mm + runtime/coreai_storage.mm ) function(coreai_configure_objc_target target) @@ -31,9 +33,11 @@ if(EXECUTORCH_BUILD_TESTS AND CMAKE_SYSTEM_NAME STREQUAL "Darwin") add_executable( coreai_host_test runtime/test/coreai_host_test.mm runtime/test/coreai_manifest_test.mm - ${_coreai_runtime_sources} + runtime/test/coreai_cache_root_test.mm runtime/test/coreai_storage_test.mm + runtime/test/coreai_filesystem_fixture.mm ${_coreai_runtime_sources} ) coreai_configure_objc_target(coreai_host_test) + target_compile_definitions(coreai_host_test PRIVATE COREAI_ASSETS_TESTING=1) find_library(COREAI_FOUNDATION_FRAMEWORK Foundation REQUIRED) target_link_libraries( coreai_host_test PRIVATE executorch GTest::gtest diff --git a/backends/apple/coreai/README.md b/backends/apple/coreai/README.md index 4b5115e1a21..1a3d96adc7a 100644 --- a/backends/apple/coreai/README.md +++ b/backends/apple/coreai/README.md @@ -50,12 +50,45 @@ architectures=["h17p"])` requests that compiler architecture; omitting the list lets the compiler emit its supported architectures for the target platform. These are Core AI architecture names, not CPU names such as `arm64`. +### Asset storage + +The assets root must be an absolute path. The application chooses it, should +reserve it for this backend, and is trusted not to rename, replace or modify its +contents while the backend uses it; the backend does not defend against other +processes of the same user rebinding paths. Written files and changed directories +are synced with `fsync`. Atomic publication uses `F_FULLFSYNC` on the temporary +file before rename and on the directory afterward. A failure before rename +preserves the old contents; a failure of the final flush reports an error even +though the new complete contents are already visible. Use a local APFS volume; +unsupported full-device flushes are reported as errors, not downgraded to `fsync`. + +Preparing the assets root sets `NSURLIsExcludedFromBackupKey` on it, which covers +everything beneath it. Ancestors are not modified. This is backup exclusion, not +a control for iCloud Drive synchronization. + +Newly created roots request +`NSFileProtectionCompleteUntilFirstUserAuthentication`. On iOS, this allows cache +access while the device is locked after its first unlock following a reboot. New +subdirectories and files inherit their parent's protection. Existing cache +contents are not migrated. Directory preparation skips creation when the root +already exists; child directories are opened first and created only when missing. +Directory creation uses default permissions filtered by the process umask, without +an explicit owner-only restriction. Existing permissions are not changed. +Backup exclusion and required durability flushes still apply on reused paths. + +Cache-root preparation owns location and backup/protection policy; descriptor +storage owns no-follow operations, publication, and synchronization. Creation +and enumeration use explicit policies, with a missing child distinguishable +from an error. Fault injection is a separate, thread-local test interface and +compiles away in production. + ## Host Tests On macOS, `EXECUTORCH_BUILD_COREAI=ON` with `EXECUTORCH_BUILD_TESTS=ON` registers the standalone `coreai_host_test` executable with CTest. It tests -manifest parsing and AOT asset selection using Foundation, without Swift or -CoreAI SDK calls. The host test target is omitted for Apple cross-builds. +manifest parsing, AOT asset selection, and filesystem storage using Foundation, +without Swift or CoreAI SDK calls. The host test target is omitted for Apple +cross-builds. CI builds and runs these tests on macOS 27. To use the same configuration from the repository root: diff --git a/backends/apple/coreai/runtime/coreai_cache_root.h b/backends/apple/coreai/runtime/coreai_cache_root.h new file mode 100644 index 00000000000..ee536e241fc --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_cache_root.h @@ -0,0 +1,25 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#import +#include +#include "coreai_file.h" + +namespace executorch::backends::coreai { + +// Resolves the default location without creating or modifying storage. +runtime::Result default_coreai_assets_root(); +// Requires an absolute directory path. With Create, makes the directory and +// excludes it from backup; otherwise a missing directory is InvalidArgument. +runtime::Result prepare_storage_root( + NSString* path, + StorageCreationMode mode); + +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_cache_root.mm b/backends/apple/coreai/runtime/coreai_cache_root.mm new file mode 100644 index 00000000000..18ca1b6f198 --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_cache_root.mm @@ -0,0 +1,103 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "coreai_cache_root.h" +#include "coreai_fault_injection.h" +#include "coreai_file.h" + +#include +#include + +namespace executorch::backends::coreai { +namespace { +using runtime::Error; +using runtime::Result; + +Error ensure_excluded_from_backup(NSURL* directory) { + NSURL* fresh = [NSURL fileURLWithPath:directory.path isDirectory:YES]; + NSNumber* excluded = nil; + NSError* error = nil; + ET_CHECK_OR_RETURN_ERROR( + [fresh getResourceValue:&excluded + forKey:NSURLIsExcludedFromBackupKey + error:&error], + AccessFailed, + "Cannot read Core AI backup exclusion: %s", + error.localizedDescription.UTF8String); + if (excluded.boolValue) { + return Error::Ok; + } + ET_CHECK_OR_RETURN_ERROR( + [fresh setResourceValue:@YES + forKey:NSURLIsExcludedFromBackupKey + error:&error], + AccessFailed, + "Cannot exclude Core AI storage from backup: %s", + error.localizedDescription.UTF8String); + return Error::Ok; +} + +} // namespace + +Result default_coreai_assets_root() { + NSURL* caches = + [NSFileManager.defaultManager URLsForDirectory:NSCachesDirectory + inDomains:NSUserDomainMask] + .firstObject; + ET_CHECK_OR_RETURN_ERROR( + caches.isFileURL, AccessFailed, "Cannot resolve Core AI cache directory"); + return [caches.path stringByAppendingPathComponent:@"executorch_coreai"]; +} + +Result prepare_storage_root( + NSString* path, + StorageCreationMode mode) { + const bool create = mode == StorageCreationMode::Create; + ET_CHECK_OR_RETURN_ERROR( + [path isKindOfClass:NSString.class] && [path hasPrefix:@"/"] && + [path rangeOfString:@"\0"].location == NSNotFound, + InvalidArgument, + "Core AI storage requires an absolute path"); + struct stat info; + const int result = + retry_eintr([&] { return stat(path.fileSystemRepresentation, &info); }); + ET_CHECK_OR_RETURN_ERROR( + result == 0 || errno == ENOENT || errno == ENOTDIR, + AccessFailed, + "Cannot inspect Core AI storage directory"); + ET_CHECK_OR_RETURN_ERROR( + result == 0 ? S_ISDIR(info.st_mode) : create && errno == ENOENT, + InvalidArgument, + "Core AI storage must be a directory"); + if (create && result != 0) { + NSDictionary* attributes = @{ + NSFileProtectionKey : + NSFileProtectionCompleteUntilFirstUserAuthentication, + }; + ET_CHECK_OR_RETURN_ERROR( + fault_point(FaultPoint::BeforeCreateDirectory) == 0, + AccessFailed, + "Cannot begin Core AI directory creation"); + NSError* error = nil; + ET_CHECK_OR_RETURN_ERROR( + [NSFileManager.defaultManager createDirectoryAtPath:path + withIntermediateDirectories:YES + attributes:attributes + error:&error], + AccessFailed, + "Cannot create Core AI storage directory: %s", + error.localizedDescription.UTF8String); + } + if (create) { + ET_CHECK_OK_OR_RETURN_ERROR(ensure_excluded_from_backup( + [NSURL fileURLWithPath:path isDirectory:YES])); + } + return path; +} + +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_fault_injection.h b/backends/apple/coreai/runtime/coreai_fault_injection.h new file mode 100644 index 00000000000..cba60c5dfb8 --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_fault_injection.h @@ -0,0 +1,34 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +namespace executorch::backends::coreai { + +enum class FaultPoint { + Write, + SyncFile, + SyncDirectory, + FullSync, + Rename, + BeforeCreateDirectory, +}; + +#if defined(COREAI_ASSETS_TESTING) && COREAI_ASSETS_TESTING +int fault_point(FaultPoint point); +namespace testing { +using FaultCallback = int (*)(FaultPoint); +extern thread_local FaultCallback fault_callback; +} // namespace testing +#else +constexpr int fault_point(FaultPoint) { + return 0; +} +#endif + +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_fault_injection.mm b/backends/apple/coreai/runtime/coreai_fault_injection.mm new file mode 100644 index 00000000000..7bbcc0a316a --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_fault_injection.mm @@ -0,0 +1,24 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include "coreai_fault_injection.h" + +namespace executorch::backends::coreai { + +#if defined(COREAI_ASSETS_TESTING) && COREAI_ASSETS_TESTING +namespace testing { +thread_local FaultCallback fault_callback = nullptr; +} + +int fault_point(FaultPoint point) { + return testing::fault_callback == nullptr ? 0 + : testing::fault_callback(point); +} +#endif + +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_file.h b/backends/apple/coreai/runtime/coreai_file.h new file mode 100644 index 00000000000..cbc991a5fae --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_file.h @@ -0,0 +1,75 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include +#include +#include +#include + +namespace executorch::backends::coreai { + +enum class StorageCreationMode { + OpenExisting, + Create, +}; + +class FileDescriptor { + public: + explicit FileDescriptor(int fd) : fd_(fd) {} + ~FileDescriptor() { + reset(-1); + } + FileDescriptor(const FileDescriptor&) = delete; + FileDescriptor& operator=(const FileDescriptor&) = delete; + int get() const { + return fd_; + } + int release() { + const int fd = fd_; + fd_ = -1; + return fd; + } + void reset(int fd) { + if (fd_ >= 0) { + // Retrying close can close a descriptor reused by another thread. + ::close(fd_); + } + fd_ = fd; + } + + private: + int fd_; +}; + +template +auto retry_eintr(F call) { + decltype(call()) result; + do { + result = call(); + } while (result == -1 && errno == EINTR); + return result; +} + +// Opens a child directory without following symlinks. Returns -1 with errno +// set, including ENOENT when the child is missing. +inline int open_directory_at(int parent_fd, const char* name) { + return retry_eintr([&] { + return openat( + parent_fd, name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + }); +} + +// Reads type and permission metadata for a child without following symlinks. +inline int lstat_at(int parent_fd, const char* name, struct stat* info) { + return retry_eintr( + [&] { return fstatat(parent_fd, name, info, AT_SYMLINK_NOFOLLOW); }); +} + +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_storage.h b/backends/apple/coreai/runtime/coreai_storage.h new file mode 100644 index 00000000000..5bcfecfa4f5 --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_storage.h @@ -0,0 +1,50 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#import +#include +#include +#include "coreai_file.h" + +namespace executorch::backends::coreai { + +runtime::Error sync_storage_directory(int fd); +struct StorageDirectory { + int fd; + bool created; +}; +// Opens one child component without following symlinks. The caller owns fd; +// a missing child with OpenExisting returns {-1, false}. Does not flush +// directories. +runtime::Result +open_storage_directory(int parent_fd, NSString* name, StorageCreationMode mode); +// Returns an independently owned descriptor for a stable coordination file. +int open_storage_shared_file(int parent_fd, const char* name); +// Creates a new file and missing parents, then syncs the file and its parent. +// On failure, attempts to remove the new file; created parents may remain. +runtime::Error write_storage_file( + int root_fd, + NSString* relative_path, + const void* bytes, + size_t size); +// Atomically replaces name with data and fully flushes the file and renamed +// entry. On error, readers see either the old or the new complete bytes. +runtime::Error publish_storage_data(int root_fd, NSString* name, NSData* data); + +enum class StorageNamePolicy { + Strict, + SkipInvalid, +}; +// Lists sorted names without changing fd's enumeration offset. +runtime::Result*> storage_children( + int fd, + StorageNamePolicy policy = StorageNamePolicy::Strict); + +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_storage.mm b/backends/apple/coreai/runtime/coreai_storage.mm new file mode 100644 index 00000000000..066c60e17ba --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_storage.mm @@ -0,0 +1,276 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "coreai_storage.h" +#include "coreai_fault_injection.h" +#include "coreai_file.h" +#include "coreai_validation.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace executorch::backends::coreai { +namespace { +using runtime::Error; +using runtime::Result; + +template +auto storage_call(FaultPoint point, F call) { + return retry_eintr([&]() -> decltype(call()) { + const int fault = fault_point(point); + if (fault != 0) { + errno = fault; + return -1; + } + return call(); + }); +} + +enum class StorageSyncMode { + Normal, + Full, +}; + +Error write_file( + int root_fd, + NSString* relative_path, + const void* data, + size_t size, + StorageSyncMode sync_mode) { + NSArray* components = + [relative_path componentsSeparatedByString:@"/"]; + ET_CHECK_OR_RETURN_ERROR( + components.count > 0 && (data != nullptr || size == 0), + InvalidArgument, + "Invalid Core AI storage write"); + for (NSString* component in components) { + ET_CHECK_OR_RETURN_ERROR( + storage_component(component), InvalidArgument, "Invalid storage path"); + } + FileDescriptor parent( + retry_eintr([&] { return fcntl(root_fd, F_DUPFD_CLOEXEC, 0); })); + ET_CHECK_OR_RETURN_ERROR( + parent.get() >= 0, AccessFailed, "Cannot open storage parent"); + for (NSUInteger i = 0; i + 1 < components.count; ++i) { + auto opened = open_storage_directory( + parent.get(), components[i], StorageCreationMode::Create); + if (!opened.ok()) + return opened.error(); + FileDescriptor next(opened.get().fd); + ET_CHECK_OK_OR_RETURN_ERROR(sync_storage_directory(parent.get())); + parent.reset(next.release()); + } + FileDescriptor file(retry_eintr([&] { + return openat( + parent.get(), + components.lastObject.fileSystemRepresentation, + O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, + 0600); + })); + ET_CHECK_OR_RETURN_ERROR( + file.get() >= 0, AccessFailed, "Cannot create Core AI storage file"); + const auto result = [&]() -> Error { + const auto* bytes = static_cast(data); + while (size > 0) { + const ssize_t count = storage_call(FaultPoint::Write, [&] { + return write(file.get(), bytes, std::min(size, size_t(1024 * 1024))); + }); + ET_CHECK_OR_RETURN_ERROR( + count > 0, AccessFailed, "Cannot write Core AI storage file"); + bytes += count; + size -= count; + } + ET_CHECK_OR_RETURN_ERROR( + storage_call(FaultPoint::SyncFile, [&] { return fsync(file.get()); }) == + 0 && + (sync_mode != StorageSyncMode::Full || + storage_call( + FaultPoint::FullSync, + [&] { return fcntl(file.get(), F_FULLFSYNC); }) == 0), + AccessFailed, + "Cannot synchronize Core AI file"); + ET_CHECK_OR_RETURN_ERROR( + ::close(file.release()) == 0, + AccessFailed, + "Cannot close Core AI storage file"); + return sync_storage_directory(parent.get()); + }(); + if (result != Error::Ok) { + if (retry_eintr([&] { + return unlinkat( + parent.get(), components.lastObject.fileSystemRepresentation, 0); + }) == 0) { + (void)sync_storage_directory(parent.get()); + } else { + ET_LOG( + Error, "Cannot remove failed Core AI storage write: errno=%d", errno); + } + } + return result; +} +} // namespace + +Result open_storage_directory( + int parent_fd, + NSString* name, + StorageCreationMode mode) { + ET_CHECK_OR_RETURN_ERROR( + storage_component(name), + InvalidArgument, + "Invalid storage directory name"); + const auto open_directory = [&] { + return open_directory_at(parent_fd, name.fileSystemRepresentation); + }; + const bool create = mode == StorageCreationMode::Create; + int fd = open_directory(); + bool created = false; + if (fd < 0 && errno == ENOENT && create) { + ET_CHECK_OR_RETURN_ERROR( + fault_point(FaultPoint::BeforeCreateDirectory) == 0, + AccessFailed, + "Cannot begin Core AI directory creation"); + const int made = retry_eintr([&] { + return mkdirat(parent_fd, name.fileSystemRepresentation, 0777); + }); + ET_CHECK_OR_RETURN_ERROR( + made == 0 || errno == EEXIST, + AccessFailed, + "Cannot create Core AI storage directory"); + created = made == 0; + fd = open_directory(); + } + if (fd < 0 && errno == ENOENT && !create) + return StorageDirectory{-1, false}; + ET_CHECK_OR_RETURN_ERROR( + fd >= 0, AccessFailed, "Cannot open Core AI storage directory"); + return StorageDirectory{fd, created}; +} + +Result*> storage_children(int fd, StorageNamePolicy policy) { + // Reopening avoids sharing the caller's directory offset. + FileDescriptor copy(retry_eintr( + [&] { return openat(fd, ".", O_RDONLY | O_DIRECTORY | O_CLOEXEC); })); + ET_CHECK_OR_RETURN_ERROR( + copy.get() >= 0, AccessFailed, "Cannot open storage directory"); + DIR* directory = fdopendir(copy.get()); + ET_CHECK_OR_RETURN_ERROR( + directory != nullptr, + AccessFailed, + "Cannot open storage directory stream"); + copy.release(); + NSMutableArray* names = [NSMutableArray array]; + Error result = Error::Ok; + while (true) { + errno = 0; + dirent* entry = readdir(directory); + if (entry == nullptr) { + if (errno == EINTR) + continue; + if (errno != 0) + result = Error::AccessFailed; + break; + } + if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) { + continue; + } + NSString* name = [[NSString alloc] initWithBytes:entry->d_name + length:strlen(entry->d_name) + encoding:NSUTF8StringEncoding]; + if (name == nil) { + if (policy == StorageNamePolicy::SkipInvalid) { + continue; + } + result = Error::InvalidExternalData; + break; + } + [names addObject:name]; + } + const int closed = closedir(directory); + ET_CHECK_OK_OR_RETURN_ERROR(result); + ET_CHECK_OR_RETURN_ERROR( + closed == 0, AccessFailed, "Cannot close storage directory stream"); + return [names sortedArrayUsingSelector:@selector(compare:)]; +} + +int open_storage_shared_file(int parent_fd, const char* name) { + // APFS can return ENOENT to a concurrent O_CREAT loser without O_EXCL. + int fd = retry_eintr([&] { + return openat( + parent_fd, + name, + O_RDWR | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, + 0600); + }); + if (fd < 0 && errno == EEXIST) { + fd = retry_eintr([&] { + return openat(parent_fd, name, O_RDWR | O_NOFOLLOW | O_CLOEXEC); + }); + } + return fd; +} + +Error sync_storage_directory(int fd) { + ET_CHECK_OR_RETURN_ERROR( + storage_call(FaultPoint::SyncDirectory, [&] { return fsync(fd); }) == 0, + AccessFailed, + "Cannot synchronize Core AI directory"); + return Error::Ok; +} + +Error write_storage_file( + int root_fd, + NSString* relative_path, + const void* data, + size_t size) { + return write_file( + root_fd, relative_path, data, size, StorageSyncMode::Normal); +} + +Error publish_storage_data(int root_fd, NSString* name, NSData* data) { + ET_CHECK_OR_RETURN_ERROR( + storage_component(name) && data != nil, + InvalidArgument, + "Invalid Core AI storage publication"); + NSString* temporary = + [@".tmp-" stringByAppendingString:NSUUID.UUID.UUIDString]; + // Bookmarks are small and costly to lose, so they get a full device flush. + auto result = write_file( + root_fd, temporary, data.bytes, data.length, StorageSyncMode::Full); + if (result == Error::Ok && storage_call(FaultPoint::Rename, [&] { + return renameat( + root_fd, + temporary.fileSystemRepresentation, + root_fd, + name.fileSystemRepresentation); + }) != 0) { + ET_LOG(Error, "Cannot publish Core AI storage data: errno=%d", errno); + result = Error::AccessFailed; + } + if (result == Error::Ok) { + // Flush the renamed entry as well as the file's pre-rename contents. + ET_CHECK_OR_RETURN_ERROR( + storage_call( + FaultPoint::FullSync, + [&] { return fcntl(root_fd, F_FULLFSYNC); }) == 0, + AccessFailed, + "Cannot fully synchronize Core AI publication directory"); + return Error::Ok; + } + if (unlinkat(root_fd, temporary.fileSystemRepresentation, 0) == 0) { + (void)sync_storage_directory(root_fd); + } + return result; +} + +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_validation.h b/backends/apple/coreai/runtime/coreai_validation.h index a3d060857dc..ea21885b19d 100644 --- a/backends/apple/coreai/runtime/coreai_validation.h +++ b/backends/apple/coreai/runtime/coreai_validation.h @@ -16,6 +16,9 @@ namespace executorch::backends::coreai { bool nonempty_string(id value); // Relative bundle path without escapes, backslashes, or empty components. bool relative_path(NSString* path); +// Single path component for an fd-relative open: no slashes, ".", or "..". +// Unlike relative_path, backslashes are ordinary bytes on this filesystem. +bool storage_component(NSString* name); // Lowercase hex of any positive length, as in the exporter model hash. bool lowercase_hex(NSString* value); // Lowercase hex of exactly length characters, as in bundle digests. diff --git a/backends/apple/coreai/runtime/coreai_validation.mm b/backends/apple/coreai/runtime/coreai_validation.mm index ea8b018f38f..54c611b81bc 100644 --- a/backends/apple/coreai/runtime/coreai_validation.mm +++ b/backends/apple/coreai/runtime/coreai_validation.mm @@ -38,6 +38,11 @@ bool relative_path(NSString* path) { return true; } +bool storage_component(NSString* name) { + return nonempty_string(name) && ![name isEqualToString:@"."] && + ![name isEqualToString:@".."] && ![name containsString:@"/"]; +} + bool lowercase_hex(NSString* value) { return nonempty_string(value) && [value rangeOfCharacterFromSet:invalid_hex_characters()].location == diff --git a/backends/apple/coreai/runtime/test/coreai_cache_root_test.mm b/backends/apple/coreai/runtime/test/coreai_cache_root_test.mm new file mode 100644 index 00000000000..b6cf8141065 --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_cache_root_test.mm @@ -0,0 +1,207 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include +#include +#include "coreai_cache_root.h" +#include "coreai_fault_scope.h" +#include "coreai_file.h" +#include "coreai_filesystem_fixture.h" +#include "coreai_storage.h" + +using namespace executorch::runtime; +using namespace executorch::backends::coreai; +using namespace executorch::backends::coreai::testing; + +TEST(CoreAICacheRootTest, PreparesRootWithRootOnlyBackupPolicy) { + NSFileManager* manager = NSFileManager.defaultManager; + TestDirectory sandbox; + ASSERT_NE(sandbox.url, nil); + NSURL* parent = [sandbox.url URLByAppendingPathComponent:@"parent"]; + ASSERT_TRUE(([manager createDirectoryAtURL:parent + withIntermediateDirectories:NO + attributes:nil + error:nil])); + ASSERT_TRUE(set_backup_excluded(sandbox.url, false)); + ASSERT_TRUE(set_backup_excluded(parent, false)); + NSURL* root = [parent URLByAppendingPathComponent:@"nested/models"]; + auto prepared = prepare_storage_root(root.path, StorageCreationMode::Create); + ASSERT_TRUE((prepared.ok() && [prepared.get() isEqualToString:root.path])); + ASSERT_TRUE((backup_excluded(root))); + ASSERT_TRUE((!backup_excluded(parent) && !backup_excluded(sandbox.url))); + ASSERT_TRUE((!backup_excluded(root.URLByDeletingLastPathComponent))); + + ASSERT_TRUE(set_backup_excluded(root, false)); + auto inspected = + prepare_storage_root(root.path, StorageCreationMode::OpenExisting); + ASSERT_TRUE((inspected.ok() && [inspected.get() isEqualToString:root.path])); + ASSERT_TRUE((!backup_excluded(root))); + ASSERT_TRUE( + (prepare_storage_root(root.path, StorageCreationMode::Create).ok() && + backup_excluded(root))); + + NSURL* missing = [sandbox.url URLByAppendingPathComponent:@"missing"]; + ASSERT_EQ( + prepare_storage_root(missing.path, StorageCreationMode::OpenExisting) + .error(), + Error::InvalidArgument); + EXPECT_FALSE([manager fileExistsAtPath:missing.path]); + EXPECT_EQ( + prepare_storage_root(@"relative", StorageCreationMode::Create).error(), + Error::InvalidArgument); + EXPECT_TRUE((!backup_excluded(parent) && !backup_excluded(sandbox.url))); +} + +TEST(CoreAICacheRootTest, SkipsCreationForExistingRoots) { + TestDirectory storage; + ASSERT_NE(storage.url, nil); + NSURL* root = [storage.url URLByAppendingPathComponent:@"cache"]; + ASSERT_TRUE( + prepare_storage_root(root.path, StorageCreationMode::Create).ok()); + StorageFaultScope fault(FaultPoint::BeforeCreateDirectory); + EXPECT_TRUE( + prepare_storage_root(root.path, StorageCreationMode::Create).ok()); + EXPECT_TRUE( + prepare_storage_root(root.path, StorageCreationMode::OpenExisting).ok()); + EXPECT_EQ(fault.matches, 0); + EXPECT_TRUE(backup_excluded(root)); + + NSURL* missing = [storage.url URLByAppendingPathComponent:@"missing"]; + EXPECT_EQ( + prepare_storage_root(missing.path, StorageCreationMode::OpenExisting) + .error(), + Error::InvalidArgument); + EXPECT_EQ(fault.matches, 0); + EXPECT_EQ( + prepare_storage_root(missing.path, StorageCreationMode::Create).error(), + Error::AccessFailed); + EXPECT_EQ(fault.hits, 1); + EXPECT_FALSE([NSFileManager.defaultManager fileExistsAtPath:missing.path]); + EXPECT_TRUE( + prepare_storage_root(missing.path, StorageCreationMode::Create).ok()); +} + +TEST(CoreAICacheRootTest, CreatesDirectoriesWithDefaultPermissions) { + TestDirectory storage; + ASSERT_NE(storage.url, nil); + NSURL* reference = [storage.url URLByAppendingPathComponent:@"reference"]; + ASSERT_EQ(mkdir(reference.fileSystemRepresentation, 0777), 0); + struct stat expected; + ASSERT_EQ(stat(reference.fileSystemRepresentation, &expected), 0); + NSURL* root = [storage.url URLByAppendingPathComponent:@"cache"]; + ASSERT_TRUE( + prepare_storage_root(root.path, StorageCreationMode::Create).ok()); + FileDescriptor directory( + open(root.fileSystemRepresentation, O_RDONLY | O_DIRECTORY | O_CLOEXEC)); + ASSERT_GE(directory.get(), 0); + ASSERT_EQ( + write_storage_file(directory.get(), @"nested/file", "x", 1), Error::Ok); + for (NSURL* entry in + @[ root, [root URLByAppendingPathComponent:@"nested"] ]) { + struct stat actual; + ASSERT_EQ(stat(entry.fileSystemRepresentation, &actual), 0); + EXPECT_EQ(actual.st_mode & 0777, expected.st_mode & 0777); + } +} + +TEST(CoreAICacheRootTest, RecreatesRemovedRoot) { + TestDirectory storage; + ASSERT_NE(storage.url, nil); + NSURL* root = [storage.url URLByAppendingPathComponent:@"cache"]; + ASSERT_TRUE( + prepare_storage_root(root.path, StorageCreationMode::Create).ok()); + ASSERT_EQ(rmdir(root.fileSystemRepresentation), 0); + ASSERT_TRUE( + prepare_storage_root(root.path, StorageCreationMode::Create).ok()); + EXPECT_TRUE(backup_excluded(root)); +} + +TEST(CoreAICacheRootTest, NewRootAndDescendantsUseFirstUnlockProtection) { + TestDirectory sandbox; + ASSERT_NE(sandbox.url, nil); + NSURL* root = [sandbox.url URLByAppendingPathComponent:@"cache"]; + ASSERT_TRUE( + prepare_storage_root(root.path, StorageCreationMode::Create).ok()); + FileDescriptor directory( + open_directory_at(AT_FDCWD, root.fileSystemRepresentation)); + ASSERT_GE(directory.get(), 0); + ASSERT_EQ( + write_storage_file(directory.get(), @"nested/model.bin", "x", 1), + Error::Ok); + for (NSURL* entry in @[ + root, + [root URLByAppendingPathComponent:@"nested"], + [root URLByAppendingPathComponent:@"nested/model.bin"] + ]) { + SCOPED_TRACE(entry.path.UTF8String); + NSError* error = nil; + NSDictionary* attributes = + [NSFileManager.defaultManager attributesOfItemAtPath:entry.path + error:&error]; + ASSERT_NE(attributes, nil) << error.description.UTF8String; + EXPECT_TRUE([attributes[NSFileProtectionKey] + isEqual:NSFileProtectionCompleteUntilFirstUserAuthentication]); + } +} + +TEST(CoreAICacheRootTest, PreservesExistingRootProtection) { + TestDirectory sandbox; + ASSERT_NE(sandbox.url, nil); + NSURL* root = [sandbox.url URLByAppendingPathComponent:@"existing"]; + NSFileManager* manager = NSFileManager.defaultManager; + NSError* error = nil; + // Set the fixture policy at creation, without reclassifying a populated + // cache. + ASSERT_TRUE([manager + createDirectoryAtURL:root + withIntermediateDirectories:NO + attributes:@{ + NSFileProtectionKey : NSFileProtectionComplete + } + error:&error]) + << error.description.UTF8String; + NSDictionary* before = [manager attributesOfItemAtPath:root.path + error:&error]; + ASSERT_NE(before, nil) << error.description.UTF8String; + ASSERT_TRUE([before[NSFileProtectionKey] isEqual:NSFileProtectionComplete]); + for (StorageCreationMode mode : + {StorageCreationMode::OpenExisting, StorageCreationMode::Create}) { + ASSERT_TRUE(prepare_storage_root(root.path, mode).ok()); + NSDictionary* after = [manager attributesOfItemAtPath:root.path + error:&error]; + ASSERT_NE(after, nil) << error.description.UTF8String; + EXPECT_TRUE([after[NSFileProtectionKey] isEqual:NSFileProtectionComplete]); + } +} + +TEST(CoreAICacheRootTest, RejectsInvalidRootsConsistently) { + TestDirectory storage; + ASSERT_NE(storage.url, nil); + NSURL* regular = [storage.url URLByAppendingPathComponent:@"regular"]; + NSData* bytes = [@"unchanged" dataUsingEncoding:NSUTF8StringEncoding]; + ASSERT_TRUE([bytes writeToURL:regular atomically:NO]); + for (StorageCreationMode mode : + {StorageCreationMode::OpenExisting, StorageCreationMode::Create}) { + SCOPED_TRACE(static_cast(mode)); + EXPECT_EQ(prepare_storage_root(nil, mode).error(), Error::InvalidArgument); + for (NSString* path in @[ + @"", + @"relative", + @"~", + @"~/cache", + @"~nobody/cache", + regular.path, + [regular.path stringByAppendingPathComponent:@"child"] + ]) { + SCOPED_TRACE(path.UTF8String); + EXPECT_EQ( + prepare_storage_root(path, mode).error(), Error::InvalidArgument); + } + } + EXPECT_TRUE([[NSData dataWithContentsOfURL:regular] isEqual:bytes]); +} diff --git a/backends/apple/coreai/runtime/test/coreai_fault_scope.h b/backends/apple/coreai/runtime/test/coreai_fault_scope.h new file mode 100644 index 00000000000..f60092cd91a --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_fault_scope.h @@ -0,0 +1,56 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#import +#include +#include "coreai_fault_injection.h" + +namespace executorch::backends::coreai::testing { + +struct StorageFaultScope { + FaultPoint operation; + int error = EIO; + int match_index = 1; + int matches = 0; + int hits = 0; + bool armed = true; + void (^observe)(FaultPoint) = nil; + + explicit StorageFaultScope(FaultPoint op) + : operation(op), previous_(current_), previous_callback_(fault_callback) { + current_ = this; + fault_callback = inject; + } + ~StorageFaultScope() { + fault_callback = previous_callback_; + current_ = previous_; + } + StorageFaultScope(const StorageFaultScope&) = delete; + StorageFaultScope& operator=(const StorageFaultScope&) = delete; + + private: + inline static thread_local StorageFaultScope* current_ = nullptr; + StorageFaultScope* previous_; + FaultCallback previous_callback_; + + static int inject(FaultPoint operation) { + auto& scope = *current_; + if (scope.observe != nil) + scope.observe(operation); + if (scope.armed && scope.operation == operation && + ++scope.matches == scope.match_index) { + ++scope.hits; + return scope.error; + } + return 0; + } +}; + +} // namespace executorch::backends::coreai::testing diff --git a/backends/apple/coreai/runtime/test/coreai_filesystem_fixture.h b/backends/apple/coreai/runtime/test/coreai_filesystem_fixture.h new file mode 100644 index 00000000000..625f1f48a8a --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_filesystem_fixture.h @@ -0,0 +1,27 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#import +#include + +namespace executorch::backends::coreai::testing { + +struct TestDirectory { + NSURL* url = nil; + TestDirectory(); + ~TestDirectory(); + TestDirectory(const TestDirectory&) = delete; + TestDirectory& operator=(const TestDirectory&) = delete; +}; + +::testing::AssertionResult backup_excluded(NSURL* url); +::testing::AssertionResult set_backup_excluded(NSURL* url, bool excluded); + +} // namespace executorch::backends::coreai::testing diff --git a/backends/apple/coreai/runtime/test/coreai_filesystem_fixture.mm b/backends/apple/coreai/runtime/test/coreai_filesystem_fixture.mm new file mode 100644 index 00000000000..6c60dddd6c5 --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_filesystem_fixture.mm @@ -0,0 +1,71 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include "coreai_filesystem_fixture.h" +#include + +namespace executorch::backends::coreai::testing { + +TestDirectory::TestDirectory() { + char pattern[] = "/private/tmp/coreai-host-test-XXXXXX"; + if (mkdtemp(pattern) == nullptr) { + ADD_FAILURE() << "mkdtemp: " << strerror(errno); + return; + } + url = [NSURL fileURLWithPath:[NSString stringWithUTF8String:pattern] + isDirectory:YES] + .URLByResolvingSymlinksInPath; +} + +TestDirectory::~TestDirectory() { + if (url == nil) + return; + EXPECT_TRUE([url.lastPathComponent hasPrefix:@"coreai-host-test-"]); + if (![url.lastPathComponent hasPrefix:@"coreai-host-test-"]) + return; + EXPECT_TRUE([NSFileManager.defaultManager removeItemAtURL:url error:nil]); +} + +::testing::AssertionResult backup_excluded(NSURL* url) { + NSURL* fresh = [NSURL fileURLWithPath:url.path]; + [fresh removeAllCachedResourceValues]; + NSNumber* value = nil; + NSError* error = nil; + if (![fresh getResourceValue:&value + forKey:NSURLIsExcludedFromBackupKey + error:&error] || + error != nil || value == nil) { + ADD_FAILURE() << "Cannot read backup flag: " << url.path.UTF8String; + return ::testing::AssertionFailure() << "Backup flag unavailable"; + } + return value.boolValue ? ::testing::AssertionSuccess() + : ::testing::AssertionFailure() + << "Not excluded: " << url.path.UTF8String; +} + +::testing::AssertionResult set_backup_excluded(NSURL* url, bool excluded) { + // Foundation can defer metadata writes; settle deliberate fixture changes. + for (int attempt = 0; attempt < 50; ++attempt) { + NSURL* fresh = [NSURL fileURLWithPath:url.path]; + [fresh removeAllCachedResourceValues]; + if (![fresh setResourceValue:@(excluded) + forKey:NSURLIsExcludedFromBackupKey + error:nil]) { + return ::testing::AssertionFailure() + << "Cannot set backup flag: " << url.path.UTF8String; + } + usleep(20000); + if (static_cast(backup_excluded(url)) == excluded) { + return ::testing::AssertionSuccess(); + } + } + return ::testing::AssertionFailure() + << "Backup flag did not settle: " << url.path.UTF8String; +} + +} // namespace executorch::backends::coreai::testing diff --git a/backends/apple/coreai/runtime/test/coreai_storage_test.mm b/backends/apple/coreai/runtime/test/coreai_storage_test.mm new file mode 100644 index 00000000000..266dfbd34bf --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_storage_test.mm @@ -0,0 +1,526 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include +#include +#include +#include +#include +#include "coreai_fault_scope.h" +#include "coreai_file.h" +#include "coreai_filesystem_fixture.h" +#include "coreai_storage.h" + +using namespace executorch::runtime; +using namespace executorch::backends::coreai; +using namespace executorch::backends::coreai::testing; + +TEST(CoreAIStorageTest, DirectoryHelpersDoNotFollowSymlinks) { + TestDirectory storage; + ASSERT_NE(storage.url, nil); + FileDescriptor root( + open_directory_at(AT_FDCWD, storage.url.fileSystemRepresentation)); + ASSERT_GE(root.get(), 0); + EXPECT_NE(fcntl(root.get(), F_GETFD) & FD_CLOEXEC, 0); + ASSERT_EQ(mkdirat(root.get(), "directory", 0700), 0); + ASSERT_EQ(symlinkat("directory", root.get(), "link"), 0); + struct stat info; + ASSERT_EQ(lstat_at(root.get(), "link", &info), 0); + EXPECT_TRUE(S_ISLNK(info.st_mode)); + ASSERT_EQ(lstat_at(root.get(), "directory", &info), 0); + EXPECT_TRUE(S_ISDIR(info.st_mode)); + EXPECT_EQ(open_directory_at(root.get(), "link"), -1); + EXPECT_EQ(open_directory_at(root.get(), "missing"), -1); + EXPECT_EQ(errno, ENOENT); + EXPECT_EQ(lstat_at(root.get(), "missing", &info), -1); + EXPECT_EQ(errno, ENOENT); +} + +TEST(CoreAIStorageTest, OpensDirectoriesAndReportsCreation) { + TestDirectory storage; + ASSERT_NE(storage.url, nil); + FileDescriptor root(open( + storage.url.fileSystemRepresentation, + O_RDONLY | O_DIRECTORY | O_CLOEXEC)); + ASSERT_GE(root.get(), 0); + auto missing = open_storage_directory( + root.get(), @"child", StorageCreationMode::OpenExisting); + ASSERT_TRUE(missing.ok()); + EXPECT_EQ(missing.get().fd, -1); + EXPECT_FALSE(missing.get().created); + + StorageFaultScope sync_fault(FaultPoint::SyncDirectory); + auto created = + open_storage_directory(root.get(), @"child", StorageCreationMode::Create); + ASSERT_TRUE(created.ok()); + FileDescriptor first(created.get().fd); + ASSERT_GE(first.get(), 0); + EXPECT_TRUE(created.get().created); + EXPECT_NE(fcntl(first.get(), F_GETFD) & FD_CLOEXEC, 0); + EXPECT_EQ(sync_fault.matches, 0); + struct stat original; + ASSERT_EQ(fstat(first.get(), &original), 0); + EXPECT_TRUE(S_ISDIR(original.st_mode)); + + StorageFaultScope create_fault(FaultPoint::BeforeCreateDirectory); + for (StorageCreationMode mode : + {StorageCreationMode::OpenExisting, StorageCreationMode::Create}) { + auto reopened = open_storage_directory(root.get(), @"child", mode); + ASSERT_TRUE(reopened.ok()); + FileDescriptor next(reopened.get().fd); + ASSERT_GE(next.get(), 0); + EXPECT_FALSE(reopened.get().created); + struct stat current; + ASSERT_EQ(fstat(next.get(), ¤t), 0); + EXPECT_EQ(current.st_ino, original.st_ino); + } + EXPECT_EQ(create_fault.matches, 0); +} + +TEST(CoreAIStorageTest, ConcurrentDirectoryCreationIsNotReportedAsOwnCreation) { + TestDirectory storage; + ASSERT_NE(storage.url, nil); + FileDescriptor root(open( + storage.url.fileSystemRepresentation, + O_RDONLY | O_DIRECTORY | O_CLOEXEC)); + ASSERT_GE(root.get(), 0); + NSURL* child = [storage.url URLByAppendingPathComponent:@"child"]; + StorageFaultScope fault(FaultPoint::BeforeCreateDirectory); + fault.armed = false; + fault.observe = ^(FaultPoint operation) { + if (operation == FaultPoint::BeforeCreateDirectory) + EXPECT_EQ(mkdir(child.fileSystemRepresentation, 0777), 0); + }; + auto opened = + open_storage_directory(root.get(), @"child", StorageCreationMode::Create); + ASSERT_TRUE(opened.ok()); + FileDescriptor directory(opened.get().fd); + EXPECT_GE(directory.get(), 0); + EXPECT_FALSE(opened.get().created); +} + +TEST(CoreAIStorageTest, RejectsInvalidDirectoryComponents) { + TestDirectory storage; + ASSERT_NE(storage.url, nil); + FileDescriptor root(open( + storage.url.fileSystemRepresentation, + O_RDONLY | O_DIRECTORY | O_CLOEXEC)); + ASSERT_GE(root.get(), 0); + EXPECT_EQ( + open_storage_directory(root.get(), nil, StorageCreationMode::OpenExisting) + .error(), + Error::InvalidArgument); + for (NSString* name in @[ @"", @".", @"..", @"nested/child" ]) { + EXPECT_EQ( + open_storage_directory( + root.get(), name, StorageCreationMode::OpenExisting) + .error(), + Error::InvalidArgument); + } +} + +TEST(CoreAIStorageTest, ListsChildrenRepeatedlyInSortedOrder) { + TestDirectory storage; + ASSERT_NE(storage.url, nil); + FileDescriptor root(open( + storage.url.fileSystemRepresentation, + O_RDONLY | O_DIRECTORY | O_CLOEXEC)); + ASSERT_TRUE((root.get() >= 0)); + NSMutableArray* expected = [NSMutableArray array]; + for (int i = 7; i >= 0; --i) { + NSString* name = [NSString stringWithFormat:@"entry-%d", i]; + FileDescriptor file(openat( + root.get(), + name.fileSystemRepresentation, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, + 0600)); + ASSERT_TRUE((file.get() >= 0)); + [expected insertObject:name atIndex:0]; + } + const off_t position = lseek(root.get(), 0, SEEK_CUR); + ASSERT_GE(position, 0); + for (int pass = 0; pass < 3; ++pass) { + errno = EIO; + auto names = storage_children(root.get()); + ASSERT_TRUE((names.ok() && [names.get() isEqual:expected])); + ASSERT_TRUE((fcntl(root.get(), F_GETFD) >= 0)); + EXPECT_EQ(lseek(root.get(), 0, SEEK_CUR), position); + } +} + +TEST(CoreAIStorageTest, ListsComposedAndDecomposedUnicodeNames) { + for (NSString* name in @[ @"caf\u00e9", @"cafe\u0301" ]) { + SCOPED_TRACE(name.UTF8String); + TestDirectory storage; + ASSERT_NE(storage.url, nil); + FileDescriptor root(open( + storage.url.fileSystemRepresentation, + O_RDONLY | O_DIRECTORY | O_CLOEXEC)); + ASSERT_GE(root.get(), 0); + FileDescriptor file(openat( + root.get(), + name.UTF8String, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, + 0600)); + ASSERT_GE(file.get(), 0); + for (StorageNamePolicy policy : + {StorageNamePolicy::Strict, StorageNamePolicy::SkipInvalid}) { + auto names = storage_children(root.get(), policy); + ASSERT_TRUE(names.ok()); + ASSERT_EQ(names.get().count, 1); + EXPECT_STREQ(names.get().firstObject.UTF8String, name.UTF8String); + } + } +} + +TEST(CoreAIStorageTest, CreatesNestedFilesWithoutOverwritingExistingContents) { + TestDirectory storage; + ASSERT_NE(storage.url, nil); + FileDescriptor root(open( + storage.url.fileSystemRepresentation, + O_RDONLY | O_DIRECTORY | O_CLOEXEC)); + ASSERT_GE(root.get(), 0); + NSData* original = [@"original" dataUsingEncoding:NSUTF8StringEncoding]; + NSData* replacement = [@"replacement" dataUsingEncoding:NSUTF8StringEncoding]; + NSString* path = @"nested/parents/model.bin"; + NSURL* file = [storage.url URLByAppendingPathComponent:path]; + ASSERT_EQ( + write_storage_file(root.get(), path, original.bytes, original.length), + Error::Ok); + EXPECT_TRUE(([[NSData dataWithContentsOfURL:file] isEqual:original])); + EXPECT_EQ( + write_storage_file( + root.get(), path, replacement.bytes, replacement.length), + Error::AccessFailed); + EXPECT_TRUE(([[NSData dataWithContentsOfURL:file] isEqual:original])); + ASSERT_EQ( + write_storage_file(root.get(), @"nested/parents/empty", nullptr, 0), + Error::Ok); + NSData* empty = [NSData + dataWithContentsOfURL: + [storage.url URLByAppendingPathComponent:@"nested/parents/empty"]]; + ASSERT_NE(empty, nil); + EXPECT_EQ(empty.length, 0); +} + +TEST(CoreAIStorageTest, ReusesSourceParentsWithoutCreation) { + TestDirectory storage; + ASSERT_NE(storage.url, nil); + FileDescriptor root(open( + storage.url.fileSystemRepresentation, + O_RDONLY | O_DIRECTORY | O_CLOEXEC)); + ASSERT_GE(root.get(), 0); + ASSERT_EQ( + write_storage_file(root.get(), @"nested/parents/first", "x", 1), + Error::Ok); + StorageFaultScope fault(FaultPoint::BeforeCreateDirectory); + EXPECT_EQ( + write_storage_file(root.get(), @"nested/parents/second", "x", 1), + Error::Ok); + EXPECT_EQ(fault.matches, 0); + EXPECT_EQ( + write_storage_file(root.get(), @"missing/file", "x", 1), + Error::AccessFailed); + EXPECT_EQ(fault.hits, 1); + EXPECT_EQ(write_storage_file(root.get(), @"missing/file", "x", 1), Error::Ok); +} + +TEST(CoreAIStorageTest, AcceptsConcurrentSourceParentCreation) { + TestDirectory storage; + ASSERT_NE(storage.url, nil); + FileDescriptor root(open( + storage.url.fileSystemRepresentation, + O_RDONLY | O_DIRECTORY | O_CLOEXEC)); + ASSERT_GE(root.get(), 0); + NSURL* nested = [storage.url URLByAppendingPathComponent:@"nested"]; + StorageFaultScope fault(FaultPoint::BeforeCreateDirectory); + fault.armed = false; + __block int creations = 0; + fault.observe = ^(FaultPoint operation) { + if (operation == FaultPoint::BeforeCreateDirectory) { + ++creations; + EXPECT_EQ(mkdir(nested.fileSystemRepresentation, 0700), 0); + } + }; + EXPECT_EQ(write_storage_file(root.get(), @"nested/file", "x", 1), Error::Ok); + EXPECT_EQ(creations, 1); +} + +TEST(CoreAIStorageTest, RejectsUnsafeSourceParentsWithoutCreation) { + TestDirectory storage; + TestDirectory target; + ASSERT_NE(storage.url, nil); + ASSERT_NE(target.url, nil); + FileDescriptor root(open( + storage.url.fileSystemRepresentation, + O_RDONLY | O_DIRECTORY | O_CLOEXEC)); + ASSERT_GE(root.get(), 0); + NSURL* link = [storage.url URLByAppendingPathComponent:@"link"]; + ASSERT_EQ( + symlink( + target.url.fileSystemRepresentation, link.fileSystemRepresentation), + 0); + NSURL* regular = [storage.url URLByAppendingPathComponent:@"regular"]; + ASSERT_TRUE([[NSData data] writeToURL:regular atomically:NO]); + StorageFaultScope fault(FaultPoint::BeforeCreateDirectory); + for (NSString* path in @[ @"link/file", @"regular/file" ]) { + EXPECT_EQ( + write_storage_file(root.get(), path, "x", 1), Error::AccessFailed); + } + EXPECT_EQ(fault.matches, 0); + EXPECT_FALSE([NSFileManager.defaultManager + fileExistsAtPath:[target.url URLByAppendingPathComponent:@"file"].path]); +} + +TEST(CoreAIStorageTest, RemovesFailedWritesAndAllowsRetry) { + const struct { + FaultPoint operation; + int match_index; + } rows[] = { + {FaultPoint::Write, 2}, + {FaultPoint::SyncFile, 1}, + {FaultPoint::SyncDirectory, 1}, + {FaultPoint::SyncDirectory, 2}}; + const std::vector bytes(1024 * 1024 + 1, 0x5a); + for (const auto& row : rows) { + SCOPED_TRACE(static_cast(row.operation)); + SCOPED_TRACE(row.match_index); + TestDirectory storage; + ASSERT_NE(storage.url, nil); + FileDescriptor root(open( + storage.url.fileSystemRepresentation, + O_RDONLY | O_DIRECTORY | O_CLOEXEC)); + ASSERT_GE(root.get(), 0); + NSString* path = @"nested/model.bin"; + NSURL* file = [storage.url URLByAppendingPathComponent:path]; + StorageFaultScope fault(row.operation); + fault.match_index = row.match_index; + EXPECT_EQ( + write_storage_file(root.get(), path, bytes.data(), bytes.size()), + Error::AccessFailed); + EXPECT_EQ(fault.hits, 1); + EXPECT_FALSE([NSFileManager.defaultManager fileExistsAtPath:file.path]); + ASSERT_EQ( + write_storage_file(root.get(), path, bytes.data(), bytes.size()), + Error::Ok); + NSData* actual = [NSData dataWithContentsOfURL:file]; + ASSERT_NE(actual, nil); + ASSERT_EQ(actual.length, bytes.size()); + EXPECT_EQ(memcmp(actual.bytes, bytes.data(), bytes.size()), 0); + } +} + +TEST(CoreAIStorageTest, SyncsEachCreatedParentAndTheWrittenFile) { + TestDirectory storage; + ASSERT_NE(storage.url, nil); + FileDescriptor root(open( + storage.url.fileSystemRepresentation, + O_RDONLY | O_DIRECTORY | O_CLOEXEC)); + ASSERT_GE(root.get(), 0); + __block std::vector operations; + StorageFaultScope fault(FaultPoint::SyncDirectory); + fault.armed = false; + fault.observe = ^(FaultPoint operation) { + operations.push_back(operation); + }; + EXPECT_EQ( + write_storage_file(root.get(), @"nested/parents/model.bin", "x", 1), + Error::Ok); + EXPECT_EQ( + operations, + (std::vector{ + FaultPoint::BeforeCreateDirectory, + FaultPoint::SyncDirectory, + FaultPoint::BeforeCreateDirectory, + FaultPoint::SyncDirectory, + FaultPoint::Write, + FaultPoint::SyncFile, + FaultPoint::SyncDirectory})); +} + +TEST(CoreAIStorageTest, RejectsUnsafeWritePathsWithoutCreatingFiles) { + TestDirectory storage; + ASSERT_NE(storage.url, nil); + FileDescriptor root(open( + storage.url.fileSystemRepresentation, + O_RDONLY | O_DIRECTORY | O_CLOEXEC)); + ASSERT_GE(root.get(), 0); + for (NSString* path in @[ + @"", + @"..", + @"../escape", + @"a/../b", + @"a/./b", + @"a//b", + @"/leading", + @"trailing/" + ]) { + SCOPED_TRACE(path.UTF8String); + EXPECT_EQ( + write_storage_file(root.get(), path, "x", 1), Error::InvalidArgument); + } + EXPECT_EQ( + write_storage_file(root.get(), @"no-data", nullptr, 1), + Error::InvalidArgument); + auto names = storage_children(root.get()); + ASSERT_TRUE(names.ok()); + EXPECT_EQ(names.get().count, 0); +} + +TEST(CoreAIStorageTest, CreatesAndReopensSharedFileWithoutTruncation) { + TestDirectory storage; + ASSERT_NE(storage.url, nil); + FileDescriptor root(open( + storage.url.fileSystemRepresentation, + O_RDONLY | O_DIRECTORY | O_CLOEXEC)); + ASSERT_GE(root.get(), 0); + FileDescriptor created(open_storage_shared_file(root.get(), "shared")); + ASSERT_GE(created.get(), 0); + ASSERT_EQ(write(created.get(), "data", 4), 4); + FileDescriptor reopened(open_storage_shared_file(root.get(), "shared")); + ASSERT_GE(reopened.get(), 0); + EXPECT_NE(created.get(), reopened.get()); + EXPECT_NE(fcntl(created.get(), F_GETFD) & FD_CLOEXEC, 0); + EXPECT_NE(fcntl(reopened.get(), F_GETFD) & FD_CLOEXEC, 0); + struct stat first, second; + ASSERT_EQ(fstat(created.get(), &first), 0); + ASSERT_EQ(fstat(reopened.get(), &second), 0); + EXPECT_EQ(first.st_ino, second.st_ino); + EXPECT_EQ(second.st_size, 4); + char bytes[4]; + ASSERT_EQ(read(reopened.get(), bytes, sizeof(bytes)), sizeof(bytes)); + EXPECT_EQ(memcmp(bytes, "data", sizeof(bytes)), 0); + EXPECT_EQ(lseek(created.get(), 0, SEEK_CUR), 4); +} + +TEST(CoreAIStorageTest, RetriesEintr) { + int calls = 0; + const ssize_t count = retry_eintr([&]() -> ssize_t { + if (++calls < 3) { + errno = EINTR; + return -1; + } + return 7; + }); + ASSERT_TRUE((calls == 3 && count == 7)); +} + +static ::testing::AssertionResult no_temporary_files(int root) { + auto children = storage_children(root); + if (!children.ok()) + return ::testing::AssertionFailure() << "Cannot list storage"; + for (NSString* child in children.get()) { + if ([child hasPrefix:@".tmp-"]) + return ::testing::AssertionFailure() << child.UTF8String; + } + return ::testing::AssertionSuccess(); +} + +TEST(CoreAIStorageTest, AtomicPublicationPreservesOldBytesOnFailure) { + TestDirectory storage; + ASSERT_NE(storage.url, nil); + FileDescriptor root(open( + storage.url.fileSystemRepresentation, + O_RDONLY | O_DIRECTORY | O_CLOEXEC)); + ASSERT_TRUE((root.get() >= 0)); + NSData* old = [@"old" dataUsingEncoding:NSUTF8StringEncoding]; + NSData* next = [@"next" dataUsingEncoding:NSUTF8StringEncoding]; + NSURL* published = [storage.url URLByAppendingPathComponent:@"published"]; + ASSERT_EQ(publish_storage_data(root.get(), @"published", old), Error::Ok); + for (FaultPoint operation : + {FaultPoint::Write, + FaultPoint::SyncFile, + FaultPoint::SyncDirectory, + FaultPoint::FullSync, + FaultPoint::Rename}) { + SCOPED_TRACE(static_cast(operation)); + StorageFaultScope fault(operation); + EXPECT_EQ( + publish_storage_data(root.get(), @"published", next), + Error::AccessFailed); + EXPECT_EQ(fault.hits, 1); + EXPECT_TRUE(([[NSData dataWithContentsOfURL:published] isEqual:old])); + EXPECT_TRUE(no_temporary_files(root.get())); + } + + ASSERT_EQ(publish_storage_data(root.get(), @"published", next), Error::Ok); + EXPECT_TRUE(([[NSData dataWithContentsOfURL:published] isEqual:next])); + EXPECT_TRUE(no_temporary_files(root.get())); +} + +TEST(CoreAIStorageTest, FullyFlushesPublicationBeforeAndAfterRename) { + TestDirectory storage; + ASSERT_NE(storage.url, nil); + FileDescriptor root(open( + storage.url.fileSystemRepresentation, + O_RDONLY | O_DIRECTORY | O_CLOEXEC)); + ASSERT_GE(root.get(), 0); + __block std::vector operations; + StorageFaultScope fault(FaultPoint::FullSync); + fault.armed = false; + fault.observe = ^(FaultPoint operation) { + operations.push_back(operation); + }; + NSData* data = [@"published" dataUsingEncoding:NSUTF8StringEncoding]; + EXPECT_EQ(publish_storage_data(root.get(), @"published", data), Error::Ok); + EXPECT_EQ( + operations, + (std::vector{ + FaultPoint::Write, + FaultPoint::SyncFile, + FaultPoint::FullSync, + FaultPoint::SyncDirectory, + FaultPoint::Rename, + FaultPoint::FullSync})); +} + +TEST(CoreAIStorageTest, ReportsPostRenameFlushFailureWithNewContentsVisible) { + TestDirectory storage; + ASSERT_NE(storage.url, nil); + FileDescriptor root(open( + storage.url.fileSystemRepresentation, + O_RDONLY | O_DIRECTORY | O_CLOEXEC)); + ASSERT_GE(root.get(), 0); + NSData* old = [@"old" dataUsingEncoding:NSUTF8StringEncoding]; + NSData* next = [@"next" dataUsingEncoding:NSUTF8StringEncoding]; + ASSERT_EQ(publish_storage_data(root.get(), @"published", old), Error::Ok); + StorageFaultScope fault(FaultPoint::FullSync); + fault.match_index = 2; + EXPECT_EQ( + publish_storage_data(root.get(), @"published", next), + Error::AccessFailed); + EXPECT_EQ(fault.hits, 1); + NSURL* published = [storage.url URLByAppendingPathComponent:@"published"]; + EXPECT_TRUE(([[NSData dataWithContentsOfURL:published] isEqual:next])); + EXPECT_TRUE(no_temporary_files(root.get())); +} + +TEST(CoreAIStorageTest, RetriesInterruptedPublicationOperations) { + TestDirectory storage; + ASSERT_NE(storage.url, nil); + FileDescriptor root(open( + storage.url.fileSystemRepresentation, + O_RDONLY | O_DIRECTORY | O_CLOEXEC)); + ASSERT_GE(root.get(), 0); + NSData* data = [@"published" dataUsingEncoding:NSUTF8StringEncoding]; + for (FaultPoint operation : + {FaultPoint::Write, + FaultPoint::SyncFile, + FaultPoint::SyncDirectory, + FaultPoint::FullSync, + FaultPoint::Rename}) { + SCOPED_TRACE(static_cast(operation)); + StorageFaultScope fault(operation); + fault.error = EINTR; + EXPECT_EQ(publish_storage_data(root.get(), @"published", data), Error::Ok); + EXPECT_EQ(fault.hits, 1); + NSURL* published = [storage.url URLByAppendingPathComponent:@"published"]; + EXPECT_TRUE(([[NSData dataWithContentsOfURL:published] isEqual:data])); + EXPECT_TRUE(no_temporary_files(root.get())); + } +}