From b694c53621dd719d783b903a987907e17f79a1f7 Mon Sep 17 00:00:00 2001 From: Scott Roy Date: Sun, 4 Oct 2026 19:48:14 -0700 Subject: [PATCH 1/4] [CoreAI] Parse the delegate manifest at runtime and run host tests in CI Summary: Add the native parser the Core AI runtime uses to read the delegate manifest, and build and run the backend's host tests in the Core AI workflow. - Runtime (`runtime/coreai_assets.{h,mm}`): parse and validate the manifest for `inline` source and `aot_compiled_inline` assets, including the ordered I/O names, file sizes and bundle digests, and select AOT assets by exact platform and Core AI architecture match with no fallback. - Tests: a Foundation-only `coreai_host_test` GoogleTest executable (no Swift, no Core AI SDK) for the parser and asset selection. Path and file-map checks are covered because they feed filesystem writes; the manifest is otherwise treated as trusted exporter output. - README: the "Runtime" manifest description, AOT architecture selection and "Host Tests". Changes outside `backends/apple/coreai` are CMake and CI only, and nothing changes unless `EXECUTORCH_BUILD_COREAI=ON`: - `tools/cmake/preset/default.cmake`: add `EXECUTORCH_BUILD_COREAI` (default OFF). - `CMakeLists.txt`: add the backend subdirectory under that option. The host test is registered inside the backend when `EXECUTORCH_BUILD_TESTS=ON`. - `.github/workflows/coreai.yml`: build the backend directory for macOS 27 and run every CTest test it registers. Test Plan: ``` # Local: configure and build only cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_OSX_DEPLOYMENT_TARGET=27.0 -DEXECUTORCH_BUILD_COREAI=ON \ -DEXECUTORCH_BUILD_TESTS=ON -DEXECUTORCH_BUILD_EXTENSION_DATA_LOADER=ON cmake --build build --target backends/apple/coreai/all # CI (macOS 27 runner, .github/workflows/coreai.yml) ctest --test-dir build/backends/apple/coreai --output-on-failure --no-tests=error ``` CTest runs `coreai_host_test`. --- .github/workflows/coreai.yml | 13 +- CMakeLists.txt | 4 + backends/apple/coreai/.clang-format | 244 +++++++++++++++ backends/apple/coreai/CMakeLists.txt | 46 +++ backends/apple/coreai/README.md | 38 +++ backends/apple/coreai/runtime/coreai_assets.h | 37 +++ .../apple/coreai/runtime/coreai_assets.mm | 296 ++++++++++++++++++ .../coreai/runtime/test/coreai_host_test.mm | 19 ++ .../runtime/test/coreai_manifest_fixture.h | 63 ++++ .../runtime/test/coreai_manifest_test.mm | 187 +++++++++++ tools/cmake/preset/default.cmake | 3 + 11 files changed, 949 insertions(+), 1 deletion(-) create mode 100644 backends/apple/coreai/.clang-format create mode 100644 backends/apple/coreai/CMakeLists.txt create mode 100644 backends/apple/coreai/runtime/coreai_assets.h create mode 100644 backends/apple/coreai/runtime/coreai_assets.mm create mode 100644 backends/apple/coreai/runtime/test/coreai_host_test.mm create mode 100644 backends/apple/coreai/runtime/test/coreai_manifest_fixture.h create mode 100644 backends/apple/coreai/runtime/test/coreai_manifest_test.mm diff --git a/.github/workflows/coreai.yml b/.github/workflows/coreai.yml index f09b5972d05..e3b9a8542f6 100644 --- a/.github/workflows/coreai.yml +++ b/.github/workflows/coreai.yml @@ -59,10 +59,21 @@ jobs: echo "::group::Install ExecuTorch and Core AI requirements" ${CONDA_RUN} python install_executorch.py --editable > /dev/null - ${CONDA_RUN} pip install -r backends/apple/coreai/requirements.txt + ${CONDA_RUN} pip install -r backends/apple/coreai/requirements.txt "cmake>=3.31,<4" ninja ${CONDA_RUN} pip list echo "::endgroup::" echo "::group::Run Core AI export tests" ${CONDA_RUN} python -m pytest backends/apple/coreai echo "::endgroup::" + + echo "::group::Build and run Core AI runtime tests" + ${CONDA_RUN} cmake -S . -B cmake-out-coreai -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_OSX_DEPLOYMENT_TARGET=27.0 \ + -DEXECUTORCH_BUILD_COREAI=ON \ + -DEXECUTORCH_BUILD_TESTS=ON \ + -DEXECUTORCH_BUILD_EXTENSION_DATA_LOADER=ON + ${CONDA_RUN} cmake --build cmake-out-coreai --target backends/apple/coreai/all -j$(sysctl -n hw.ncpu) + ${CONDA_RUN} ctest --test-dir cmake-out-coreai/backends/apple/coreai --output-on-failure --no-tests=error + echo "::endgroup::" diff --git a/CMakeLists.txt b/CMakeLists.txt index 7ae92a457b5..0620bd5ad27 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -843,6 +843,10 @@ if(EXECUTORCH_BUILD_COREML) list(APPEND _executorch_backends coremldelegate) endif() +if(EXECUTORCH_BUILD_COREAI) + add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/backends/apple/coreai) +endif() + if(EXECUTORCH_BUILD_MLX) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/backends/mlx) list(APPEND _executorch_backends mlxdelegate) diff --git a/backends/apple/coreai/.clang-format b/backends/apple/coreai/.clang-format new file mode 100644 index 00000000000..8bd9536477b --- /dev/null +++ b/backends/apple/coreai/.clang-format @@ -0,0 +1,244 @@ +# Root style without its Cpp-only Language key, so Objective-C headers format too. +--- +AccessModifierOffset: -1 +AlignAfterOpenBracket: AlwaysBreak +AlignArrayOfStructures: None +AlignConsecutiveAssignments: + Enabled: false + AcrossEmptyLines: false + AcrossComments: false + AlignCompound: false + AlignFunctionPointers: false + PadOperators: true +AlignConsecutiveBitFields: + Enabled: false + AcrossEmptyLines: false + AcrossComments: false + AlignCompound: false + AlignFunctionPointers: false + PadOperators: true +AlignConsecutiveDeclarations: + Enabled: false + AcrossEmptyLines: false + AcrossComments: false + AlignCompound: false + AlignFunctionPointers: false + PadOperators: true +AlignConsecutiveMacros: + Enabled: false + AcrossEmptyLines: false + AcrossComments: false + AlignCompound: false + AlignFunctionPointers: false + PadOperators: true +AlignConsecutiveShortCaseStatements: + Enabled: false + AcrossEmptyLines: false + AcrossComments: false + AlignCaseColons: false +AlignEscapedNewlines: Left +AlignOperands: DontAlign +AlignTrailingComments: + Kind: Never + OverEmptyLines: 0 +AllowAllArgumentsOnNextLine: true +AllowAllParametersOfDeclarationOnNextLine: false +AllowBreakBeforeNoexceptSpecifier: Never +AllowShortBlocksOnASingleLine: Never +AllowShortCaseLabelsOnASingleLine: false +AllowShortCompoundRequirementOnASingleLine: true +AllowShortEnumsOnASingleLine: true +AllowShortFunctionsOnASingleLine: Empty +AllowShortIfStatementsOnASingleLine: Never +AllowShortLambdasOnASingleLine: All +AllowShortLoopsOnASingleLine: false +AlwaysBreakAfterDefinitionReturnType: None +AlwaysBreakAfterReturnType: None +AlwaysBreakBeforeMultilineStrings: true +AlwaysBreakTemplateDeclarations: Yes +AttributeMacros: + - __capability +BinPackArguments: false +BinPackParameters: false +BitFieldColonSpacing: Both +BraceWrapping: + AfterCaseLabel: false + AfterClass: false + AfterControlStatement: Never + AfterEnum: false + AfterExternBlock: false + AfterFunction: false + AfterNamespace: false + AfterObjCDeclaration: false + AfterStruct: false + AfterUnion: false + BeforeCatch: false + BeforeElse: false + BeforeLambdaBody: false + BeforeWhile: false + IndentBraces: false + SplitEmptyFunction: true + SplitEmptyRecord: true + SplitEmptyNamespace: true +BreakAdjacentStringLiterals: true +BreakAfterAttributes: Leave +BreakAfterJavaFieldAnnotations: false +BreakArrays: true +BreakBeforeBinaryOperators: None +BreakBeforeConceptDeclarations: Always +BreakBeforeBraces: Attach +BreakBeforeInlineASMColon: OnlyMultiline +BreakBeforeTernaryOperators: true +BreakConstructorInitializers: BeforeColon +BreakInheritanceList: BeforeColon +BreakStringLiterals: false +ColumnLimit: 80 +CommentPragmas: '^ IWYU pragma:' +CompactNamespaces: false +ConstructorInitializerIndentWidth: 4 +ContinuationIndentWidth: 4 +Cpp11BracedListStyle: true +DerivePointerAlignment: false +DisableFormat: false +EmptyLineAfterAccessModifier: Never +EmptyLineBeforeAccessModifier: LogicalBlock +ExperimentalAutoDetectBinPacking: false +FixNamespaceComments: true +ForEachMacros: + - FOR_EACH + - FOR_EACH_R + - FOR_EACH_RANGE +IfMacros: + - KJ_IF_MAYBE +IncludeBlocks: Preserve +IncludeCategories: + - Regex: '^<.*\.h(pp)?>' + Priority: 1 + SortPriority: 0 + CaseSensitive: false + - Regex: '^<.*' + Priority: 2 + SortPriority: 0 + CaseSensitive: false + - Regex: '.*' + Priority: 3 + SortPriority: 0 + CaseSensitive: false +IncludeIsMainRegex: '(Test)?$' +IncludeIsMainSourceRegex: '' +IndentAccessModifiers: false +IndentCaseBlocks: false +IndentCaseLabels: true +IndentExternBlock: AfterExternBlock +IndentGotoLabels: true +IndentPPDirectives: None +IndentRequiresClause: true +IndentWidth: 2 +IndentWrappedFunctionNames: false +InsertBraces: false +InsertNewlineAtEOF: false +InsertTrailingCommas: None +IntegerLiteralSeparator: + Binary: 0 + BinaryMinDigits: 0 + Decimal: 0 + DecimalMinDigits: 0 + Hex: 0 + HexMinDigits: 0 +JavaScriptQuotes: Leave +JavaScriptWrapImports: true +KeepEmptyLinesAtTheStartOfBlocks: false +KeepEmptyLinesAtEOF: false +LambdaBodyIndentation: Signature +LineEnding: DeriveLF +MacroBlockBegin: '' +MacroBlockEnd: '' +MaxEmptyLinesToKeep: 1 +NamespaceIndentation: None +ObjCBinPackProtocolList: Auto +ObjCBlockIndentWidth: 2 +ObjCBreakBeforeNestedBlockParam: true +ObjCSpaceAfterProperty: false +ObjCSpaceBeforeProtocolList: false +PackConstructorInitializers: NextLine +PenaltyBreakAssignment: 2 +PenaltyBreakBeforeFirstCallParameter: 1 +PenaltyBreakComment: 300 +PenaltyBreakFirstLessLess: 120 +PenaltyBreakOpenParenthesis: 0 +PenaltyBreakScopeResolution: 500 +PenaltyBreakString: 1000 +PenaltyBreakTemplateDeclaration: 10 +PenaltyExcessCharacter: 1000000 +PenaltyIndentedWhitespace: 0 +PenaltyReturnTypeOnItsOwnLine: 200 +PointerAlignment: Left +PPIndentWidth: -1 +QualifierAlignment: Leave +ReferenceAlignment: Pointer +ReflowComments: true +RemoveBracesLLVM: false +RemoveParentheses: Leave +RemoveSemicolon: false +RequiresClausePosition: OwnLine +RequiresExpressionIndentation: OuterScope +SeparateDefinitionBlocks: Leave +ShortNamespaceLines: 1 +SkipMacroDefinitionBody: false +SortIncludes: CaseSensitive +SortJavaStaticImport: Before +SortUsingDeclarations: LexicographicNumeric +SpaceAfterCStyleCast: false +SpaceAfterLogicalNot: false +SpaceAfterTemplateKeyword: true +SpaceAroundPointerQualifiers: Default +SpaceBeforeAssignmentOperators: true +SpaceBeforeCaseColon: false +SpaceBeforeCpp11BracedList: false +SpaceBeforeCtorInitializerColon: true +SpaceBeforeInheritanceColon: true +SpaceBeforeJsonColon: false +SpaceBeforeParens: ControlStatements +SpaceBeforeParensOptions: + AfterControlStatements: true + AfterForeachMacros: true + AfterFunctionDefinitionName: false + AfterFunctionDeclarationName: false + AfterIfMacros: true + AfterOverloadedOperator: false + AfterPlacementOperator: true + AfterRequiresInClause: false + AfterRequiresInExpression: false + BeforeNonEmptyParentheses: false +SpaceBeforeRangeBasedForLoopColon: true +SpaceBeforeSquareBrackets: false +SpaceInEmptyBlock: false +SpacesBeforeTrailingComments: 1 +SpacesInAngles: Never +SpacesInContainerLiterals: true +SpacesInLineCommentPrefix: + Minimum: 1 + Maximum: -1 +SpacesInParens: Never +SpacesInParensOptions: + InCStyleCasts: false + InConditionalStatements: false + InEmptyParentheses: false + Other: false +SpacesInSquareBrackets: false +Standard: Latest +StatementAttributeLikeMacros: + - Q_EMIT +StatementMacros: + - Q_UNUSED + - QT_REQUIRE_VERSION +TabWidth: 8 +UseTab: Never +VerilogBreakBetweenInstancePorts: true +WhitespaceSensitiveMacros: + - BOOST_PP_STRINGIZE + - CF_SWIFT_NAME + - NS_SWIFT_NAME + - PP_STRINGIZE + - STRINGIZE +... diff --git a/backends/apple/coreai/CMakeLists.txt b/backends/apple/coreai/CMakeLists.txt new file mode 100644 index 00000000000..312f30c9d0c --- /dev/null +++ b/backends/apple/coreai/CMakeLists.txt @@ -0,0 +1,46 @@ +# 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. + +if(NOT APPLE) + message(FATAL_ERROR "Core AI host tests require Apple platforms") +endif() + +enable_language(OBJCXX) + +set(_coreai_runtime_sources runtime/coreai_assets.mm) + +function(coreai_configure_objc_target target) + target_include_directories( + ${target} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/runtime + ) + set_target_properties( + ${target} PROPERTIES OBJCXX_STANDARD 17 OBJCXX_STANDARD_REQUIRED YES + ) + target_compile_options( + ${target} + PRIVATE "$<$:-fobjc-arc;-fno-exceptions;-fno-rtti>" + ) +endfunction() + +if(EXECUTORCH_BUILD_TESTS) + if(NOT CMAKE_SYSTEM_NAME STREQUAL "Darwin") + message(FATAL_ERROR "Core AI host tests require a macOS build") + endif() + add_executable( + coreai_host_test + runtime/test/coreai_host_test.mm runtime/test/coreai_manifest_test.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 + ${COREAI_FOUNDATION_FRAMEWORK} + ) + add_test(NAME coreai_host_test COMMAND coreai_host_test) + set_tests_properties(coreai_host_test PROPERTIES TIMEOUT 120) +endif() diff --git a/backends/apple/coreai/README.md b/backends/apple/coreai/README.md index 2e6ce4d61b4..99ca602bbdd 100644 --- a/backends/apple/coreai/README.md +++ b/backends/apple/coreai/README.md @@ -23,3 +23,41 @@ python -m pytest backends/apple/coreai `CoreAIAOTCompileTest` needs `xcrun coreai-build` from the Metal Toolchain (`xcodebuild -downloadComponent MetalToolchain`) and skips without it. + +## Runtime + +The delegate manifest describes `.aimodel` source bundles, which require +`inline` packaging, and AOT-compiled `.aimodelc` bundles, which require +`aot_compiled_inline`. Both formats require a `function` name, and ordered +`input_names` and `output_names` matching the converter's bindings. Dictionary +iteration order is not a binding contract. The `files` object maps relative +filenames to byte sizes; `bundle_digests` maps each bundle basename to its +export-time SHA-256 digest. + +### AOT architecture selection + +Configure AOT export with `AOTCompileConfig`, including the target `platform` and +optional `architectures` list. For example, `AOTCompileConfig(platform="iOS", +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`. + +## Host Tests + +`EXECUTORCH_BUILD_COREAI=ON` with `EXECUTORCH_BUILD_TESTS=ON` registers the +standalone `coreai_host_test` executable with CTest. It compiles the runtime +sources without Swift or CoreAI, routing any SDK calls through a fake bridge, so +it also runs on macOS 26. Host tests do not establish real SDK behavior. + +From the repository root: + +```bash +cmake -S . -B -G Ninja \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_OSX_DEPLOYMENT_TARGET=26.0 \ + -DEXECUTORCH_BUILD_COREAI=ON -DEXECUTORCH_BUILD_TESTS=ON \ + -DEXECUTORCH_BUILD_EXTENSION_DATA_LOADER=ON \ + -DEXECUTORCH_ENABLE_PROGRAM_VERIFICATION=ON \ + -DEXECUTORCH_BUILD_EXECUTOR_RUNNER=OFF +cmake --build --target coreai_host_test +ctest --test-dir -R '^coreai_host_test$' --output-on-failure +``` diff --git a/backends/apple/coreai/runtime/coreai_assets.h b/backends/apple/coreai/runtime/coreai_assets.h new file mode 100644 index 00000000000..ebf84a3bf01 --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_assets.h @@ -0,0 +1,37 @@ +/* + * 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 { + +struct Manifest { + NSString* hash = nil; + NSString* path = nil; + NSString* function = nil; + NSArray* inputs = nil; + NSArray* outputs = nil; + NSDictionary* files = nil; + NSDictionary* bundle_digests = nil; + bool aot_compiled = false; + NSString* platform = nil; + NSDictionary* archs = nil; + NSOperatingSystemVersion minimum_version{}; +}; + +runtime::Result parse_manifest(NSData* data); +// Resolve an already validated manifest before reading or materializing assets. +runtime::Result select_assets( + const Manifest& manifest, + NSString* device_architecture, + NSString* platform); + +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_assets.mm b/backends/apple/coreai/runtime/coreai_assets.mm new file mode 100644 index 00000000000..d28cb8e1d55 --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_assets.mm @@ -0,0 +1,296 @@ +/* + * 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_assets.h" +#include +#include + +namespace executorch::backends::coreai { +namespace { +using runtime::Error; +using runtime::Result; + +bool nonempty_string(id value) { + return [value isKindOfClass:NSString.class] && [value length] > 0 && + [value rangeOfString:@"\0"].location == NSNotFound; +} + +bool names_array(id value) { + if (![value isKindOfClass:NSArray.class]) { + return false; + } + NSMutableSet* seen = [NSMutableSet set]; + for (id name in value) { + if (!nonempty_string(name) || [seen containsObject:name]) { + return false; + } + [seen addObject:name]; + } + return true; +} + +bool relative_path(NSString* path) { + if (!nonempty_string(path) || path.isAbsolutePath || + [path containsString:@"\\"]) { + return false; + } + for (NSString* component in [path componentsSeparatedByString:@"/"]) { + if (component.length == 0 || [component isEqualToString:@"."] || + [component isEqualToString:@".."]) { + return false; + } + } + return true; +} + +bool file_size(id value) { + return [value isKindOfClass:NSNumber.class] && + CFGetTypeID((__bridge CFTypeRef)value) != CFBooleanGetTypeID() && + !CFNumberIsFloatType((__bridge CFNumberRef)value) && + [value compare:@0] != NSOrderedAscending && + [value compare:@(std::numeric_limits::max())] != + NSOrderedDescending && + [value isEqualToNumber:@([value unsignedLongLongValue])]; +} + +Error validate_files( + NSDictionary* filenames, + NSSet* bundles) { + ET_CHECK_OR_RETURN_ERROR( + [filenames isKindOfClass:NSDictionary.class] && filenames.count > 0, + InvalidProgram, + "Core AI manifest requires asset file sizes"); + NSSet* files = [NSSet setWithArray:filenames.allKeys]; + NSMutableSet* populated = [NSMutableSet set]; + for (NSString* file in filenames) { + ET_CHECK_OR_RETURN_ERROR( + relative_path(file) && file_size(filenames[file]) && + [bundles containsObject:[file componentsSeparatedByString:@"/"] + .firstObject] && + [file containsString:@"/"] && + [file canBeConvertedToEncoding:NSUTF8StringEncoding], + InvalidProgram, + "Invalid Core AI asset path or size"); + [populated addObject:[file componentsSeparatedByString:@"/"].firstObject]; + NSString* parent = file.stringByDeletingLastPathComponent; + while (parent.length > 0) { + ET_CHECK_OR_RETURN_ERROR( + ![files containsObject:parent], + InvalidProgram, + "Conflicting Core AI asset paths"); + parent = parent.stringByDeletingLastPathComponent; + } + } + ET_CHECK_OR_RETURN_ERROR( + [populated isEqualToSet:bundles], + InvalidProgram, + "Core AI manifest requires files for every declared bundle"); + return Error::Ok; +} + +bool parse_version(id value, NSOperatingSystemVersion& version) { + // A null floor means the exporter used the SDK's save_asset default. + if (value == NSNull.null) { + version = {}; + return true; + } + if (!nonempty_string(value)) { + return false; + } + NSArray* parts = [value componentsSeparatedByString:@"."]; + if (parts.count > 3) { + return false; + } + NSInteger numbers[3] = {0, 0, 0}; + NSCharacterSet* invalid = [[NSCharacterSet + characterSetWithCharactersInString:@"0123456789"] invertedSet]; + for (NSUInteger i = 0; i < parts.count; ++i) { + NSString* part = parts[i]; + if (part.length == 0 || part.length > 6 || + [part rangeOfCharacterFromSet:invalid].location != NSNotFound) { + return false; + } + numbers[i] = part.integerValue; + } + version = {numbers[0], numbers[1], numbers[2]}; + return numbers[0] > 0; +} + +} // namespace + +Result parse_manifest(NSData* data) { + NSError* error = nil; + id object = [NSJSONSerialization JSONObjectWithData:data + options:0 + error:&error]; + ET_CHECK_OR_RETURN_ERROR( + [object isKindOfClass:NSDictionary.class], + InvalidProgram, + "Invalid Core AI manifest JSON"); + NSDictionary* dict = object; + NSString* packaging = dict[@"packaging"]; + ET_CHECK_OR_RETURN_ERROR( + [packaging isKindOfClass:NSString.class], + InvalidProgram, + "Missing Core AI packaging"); + ET_CHECK_OR_RETURN_ERROR( + [packaging isEqualToString:@"inline"] || + [packaging isEqualToString:@"aot_compiled_inline"], + NotSupported, + "Unsupported Core AI packaging; expected .aimodel or AOT .aimodelc"); + Manifest manifest; + manifest.aot_compiled = [packaging hasPrefix:@"aot_compiled_"]; + manifest.hash = dict[@"hash"]; + manifest.path = dict[@"path"]; + manifest.function = dict[@"function"]; + manifest.inputs = dict[@"input_names"]; + manifest.outputs = dict[@"output_names"]; + ET_CHECK_OR_RETURN_ERROR( + nonempty_string(manifest.hash) && + [manifest.hash + rangeOfCharacterFromSet:[[NSCharacterSet + characterSetWithCharactersInString: + @"0123456789abcdef"] invertedSet]] + .location == NSNotFound, + InvalidProgram, + "Invalid Core AI model hash"); + NSMutableSet* bundles = [NSMutableSet set]; + if (manifest.aot_compiled) { + manifest.platform = dict[@"platform"]; + manifest.archs = dict[@"archs"]; + ET_CHECK_OR_RETURN_ERROR( + nonempty_string(manifest.platform), + InvalidProgram, + "Core AI AOT manifest requires a target platform; re-export the model"); + ET_CHECK_OR_RETURN_ERROR( + [manifest.archs isKindOfClass:NSDictionary.class] && + manifest.archs.count > 0, + InvalidProgram, + "Core AI AOT manifest requires a nonempty architecture map; " + "re-export the model"); + for (id arch in manifest.archs) { + ET_CHECK_OR_RETURN_ERROR( + relative_path(arch) && ![arch containsString:@"/"] && + [arch canBeConvertedToEncoding:NSUTF8StringEncoding], + InvalidProgram, + "Invalid Core AI AOT architecture name"); + NSString* bundle = [NSString stringWithFormat:@"model.%@.aimodelc", arch]; + id path = manifest.archs[arch]; + ET_CHECK_OR_RETURN_ERROR( + relative_path(path) && + [path isEqualToString:[manifest.hash + stringByAppendingPathComponent:bundle]], + InvalidProgram, + "Invalid Core AI AOT bundle path for architecture %s", + [arch UTF8String]); + [bundles addObject:bundle]; + } + manifest.path = nil; + } else { + ET_CHECK_OR_RETURN_ERROR( + relative_path(manifest.path) && + [manifest.path + isEqualToString:[manifest.hash + stringByAppendingString:@"/model.aimodel"]], + InvalidProgram, + "Invalid Core AI source bundle path"); + [bundles addObject:@"model.aimodel"]; + } + ET_CHECK_OR_RETURN_ERROR( + nonempty_string(manifest.function) && names_array(manifest.inputs) && + names_array(manifest.outputs), + InvalidProgram, + "Core AI manifest requires ordered input/output names; re-export the " + "model"); + ET_CHECK_OR_RETURN_ERROR( + parse_version(dict[@"min_deployment_version"], manifest.minimum_version), + InvalidProgram, + "Invalid Core AI minimum deployment version"); + manifest.files = dict[@"files"]; + ET_CHECK_OK_OR_RETURN_ERROR(validate_files(manifest.files, bundles)); + manifest.bundle_digests = dict[@"bundle_digests"]; + ET_CHECK_OR_RETURN_ERROR( + [manifest.bundle_digests isKindOfClass:NSDictionary.class] && + [[NSSet setWithArray:manifest.bundle_digests.allKeys] + isEqualToSet:bundles], + InvalidProgram, + "Core AI bundle digests must exactly cover declared bundles"); + for (NSString* bundle in bundles) { + id digest = manifest.bundle_digests[bundle]; + ET_CHECK_OR_RETURN_ERROR( + nonempty_string(digest) && [digest length] == 64 && + [digest + rangeOfCharacterFromSet: + [[NSCharacterSet + characterSetWithCharactersInString:@"0123456789abcdef"] + invertedSet]] + .location == NSNotFound, + InvalidProgram, + "Invalid Core AI exported bundle digest"); + } + return manifest; +} + +Result select_assets( + const Manifest& manifest, + NSString* device_architecture, + NSString* platform) { + if (!manifest.aot_compiled) { + return manifest; + } + ET_CHECK_OR_RETURN_ERROR( + [manifest.platform isEqualToString:platform], + DelegateInvalidCompatibility, + "Core AI AOT target platform %s does not match runtime platform %s; " + "re-export the model for this platform", + manifest.platform.UTF8String, + platform.UTF8String); + NSArray* architectures = [manifest.archs.allKeys + sortedArrayUsingComparator:^NSComparisonResult(NSString* a, NSString* b) { + return [a compare:b options:NSLiteralSearch]; + }]; + NSString* available = [architectures componentsJoinedByString:@", "]; + ET_CHECK_OR_RETURN_ERROR( + nonempty_string(device_architecture), + DelegateInvalidCompatibility, + "Core AI SDK device architecture is unavailable (%s); available " + "architectures: [%s]; re-export the model as .aimodel or for this device", + device_architecture == nil ? "nil" : device_architecture.UTF8String, + available.UTF8String); + NSString* path = manifest.archs[device_architecture]; + ET_CHECK_OR_RETURN_ERROR( + path != nil, + DelegateInvalidCompatibility, + "Core AI AOT has no bundle for device architecture %s; available " + "architectures: [%s]; re-export the model for this device architecture", + device_architecture.UTF8String, + available.UTF8String); + Manifest selected = manifest; + selected.path = path; + NSString* prefix = [path.lastPathComponent stringByAppendingString:@"/"]; + NSMutableDictionary* files = + [NSMutableDictionary dictionary]; + for (NSString* file in manifest.files) { + if ([file hasPrefix:prefix]) { + files[file] = manifest.files[file]; + } + } + ET_CHECK_OR_RETURN_ERROR( + files.count > 0, + InvalidProgram, + "Core AI manifest has no files for selected device architecture %s; " + "re-export the model", + device_architecture.UTF8String); + selected.files = files; + selected.bundle_digests = @{ + path.lastPathComponent : manifest.bundle_digests[path.lastPathComponent] + }; + return selected; +} + +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/test/coreai_host_test.mm b/backends/apple/coreai/runtime/test/coreai_host_test.mm new file mode 100644 index 00000000000..5c857dfddf4 --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_host_test.mm @@ -0,0 +1,19 @@ +/* + * 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 +#include +#include + +int main(int argc, char** argv) { + executorch::runtime::runtime_init(); + @autoreleasepool { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); + } +} diff --git a/backends/apple/coreai/runtime/test/coreai_manifest_fixture.h b/backends/apple/coreai/runtime/test/coreai_manifest_fixture.h new file mode 100644 index 00000000000..5155ed8dd77 --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_manifest_fixture.h @@ -0,0 +1,63 @@ +/* + * 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 +#import "coreai_assets.h" + +namespace executorch::backends::coreai::testing { + +// Synthetic identities keep host tests independent of the exporter hash. +inline NSString* fixture_identity(char digit) { + EXPECT_TRUE((digit >= '0' && digit <= '9') || (digit >= 'a' && digit <= 'f')); + return [NSString stringWithUTF8String:std::string(64, digit).c_str()]; +} + +inline NSMutableDictionary* manifest_dict() { + return [@{ + @"packaging" : @"inline", + @"hash" : @"ab", + @"path" : @"ab/model.aimodel", + @"function" : @"main", + @"input_names" : @[ @"input_0" ], + @"output_names" : @[ @"output_0" ], + @"files" : @{@"model.aimodel/graph.bin" : @11}, + @"bundle_digests" : @{@"model.aimodel" : fixture_identity('1')}, + @"min_deployment_version" : @"26.0" + } mutableCopy]; +} + +inline NSMutableDictionary* aot_manifest_dict() { + auto dict = manifest_dict(); + [dict removeObjectForKey:@"path"]; + dict[@"packaging"] = @"aot_compiled_inline"; + dict[@"platform"] = @"macOS"; + dict[@"archs"] = @{ + @"arch_b" : @"ab/model.arch_b.aimodelc", + @"arch_a" : @"ab/model.arch_a.aimodelc" + }; + dict[@"files"] = @{ + @"model.arch_b.aimodelc/nested/weights.bin" : @7, + @"model.arch_a.aimodelc/graph.bin" : @14, + @"model.arch_b.aimodelc/graph.bin" : @14, + @"model.arch_a.aimodelc/nested/weights.bin" : @7 + }; + dict[@"bundle_digests"] = @{ + @"model.arch_a.aimodelc" : fixture_identity('a'), + @"model.arch_b.aimodelc" : fixture_identity('b') + }; + return dict; +} + +inline NSData* encode(NSDictionary* dict) { + return [NSJSONSerialization dataWithJSONObject:dict options:0 error:nil]; +} + +} // namespace executorch::backends::coreai::testing diff --git a/backends/apple/coreai/runtime/test/coreai_manifest_test.mm b/backends/apple/coreai/runtime/test/coreai_manifest_test.mm new file mode 100644 index 00000000000..337a939c4d8 --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_manifest_test.mm @@ -0,0 +1,187 @@ +/* + * 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_manifest_fixture.h" +#include + +using namespace executorch::runtime; +using namespace executorch::backends::coreai; +using namespace executorch::backends::coreai::testing; + +TEST(CoreAIManifestTest, AcceptsInlineAndRejectsInvalidJson) { + EXPECT_TRUE(parse_manifest(encode(manifest_dict())).ok()); + EXPECT_FALSE(parse_manifest([@"[]" dataUsingEncoding:NSUTF8StringEncoding]).ok()); + EXPECT_FALSE(parse_manifest([@"garbage" dataUsingEncoding:NSUTF8StringEncoding]).ok()); +} + +TEST(CoreAIManifestTest, RejectsUnknownPackaging) { + auto dict = manifest_dict(); + dict[@"packaging"] = @"unknown"; + EXPECT_EQ(parse_manifest(encode(dict)).error(), Error::NotSupported); +} + +TEST(CoreAIManifestTest, RejectsUnsafeSourcePaths) { + for (NSString* path in @[ + @"../model.aimodel", @"/ab/model.aimodel", @"ab/../model.aimodel", + @"xx/model.aimodel" + ]) { + SCOPED_TRACE(path.UTF8String); + auto dict = manifest_dict(); + dict[@"path"] = path; + EXPECT_FALSE(parse_manifest(encode(dict)).ok()); + } +} + +TEST(CoreAIManifestTest, RejectsInvalidFileMaps) { + for (id files in @[ + @[], @{}, NSNull.null, @"files", + @{@"model.aimodel/../escape" : @1}, + @{@"model.aimodel/bad\\name" : @1}, + @{@"/tmp/file" : @1}, + @{@"model.aimodel/one" : @1, @"model.aimodel/one/two" : @2} + ]) { + SCOPED_TRACE([files description].UTF8String); + auto dict = manifest_dict(); + dict[@"files"] = files; + EXPECT_FALSE(parse_manifest(encode(dict)).ok()); + } +} + +TEST(CoreAIManifestTest, RejectsInvalidInputNames) { + for (id names in @[ @[ @"same", @"same" ], @[ @1 ], @[ @"" ], @"input", NSNull.null ]) { + SCOPED_TRACE([names description].UTF8String); + auto dict = manifest_dict(); + dict[@"input_names"] = names; + EXPECT_FALSE(parse_manifest(encode(dict)).ok()); + } +} + +TEST(CoreAIManifestTest, ValidatesDeploymentFloor) { + for (NSString* version in @[ @"", @"27.x", @"27.0.0.0" ]) { + SCOPED_TRACE(version.UTF8String); + auto dict = manifest_dict(); + dict[@"min_deployment_version"] = version; + EXPECT_FALSE(parse_manifest(encode(dict)).ok()); + } + auto dict = manifest_dict(); + dict[@"min_deployment_version"] = NSNull.null; + EXPECT_TRUE(parse_manifest(encode(dict)).ok()); +} + +TEST(CoreAIManifestTest, RequiresMetadataMaps) { + auto dict = manifest_dict(); + ASSERT_TRUE(parse_manifest(encode(dict)).ok()); + for (NSString* key in @[ @"files", @"bundle_digests" ]) { + SCOPED_TRACE(key.UTF8String); + NSMutableDictionary* invalid = [dict mutableCopy]; + [invalid removeObjectForKey:key]; + EXPECT_EQ(parse_manifest(encode(invalid)).error(), Error::InvalidProgram); + invalid[key] = @[]; + EXPECT_EQ(parse_manifest(encode(invalid)).error(), Error::InvalidProgram); + } +} + +TEST(CoreAIManifestTest, RejectsInvalidFileSizes) { + for (id size in @[ @(-1), @0.5, @(std::numeric_limits::max()) ]) { + SCOPED_TRACE([size description].UTF8String); + auto dict = manifest_dict(); + dict[@"files"] = @{@"model.aimodel/graph.bin" : size}; + EXPECT_EQ(parse_manifest(encode(dict)).error(), Error::InvalidProgram); + } +} + +TEST(CoreAIManifestTest, AcceptsIntegralFileSizes) { + for (NSNumber* size in @[ @0, @11, @(std::numeric_limits::max()) ]) { + SCOPED_TRACE(size.description.UTF8String); + auto dict = manifest_dict(); + dict[@"files"] = @{@"model.aimodel/graph.bin" : size}; + EXPECT_TRUE(parse_manifest(encode(dict)).ok()); + } +} + +TEST(CoreAIManifestTest, RequiresExactLowercaseDigests) { + for (NSString* digest in @[ + [fixture_identity('a') substringFromIndex:1], fixture_identity('a').uppercaseString, + [@"g" stringByPaddingToLength:64 withString:@"g" startingAtIndex:0] + ]) { + SCOPED_TRACE(digest.UTF8String); + auto dict = manifest_dict(); + dict[@"bundle_digests"] = @{@"model.aimodel" : digest}; + EXPECT_EQ(parse_manifest(encode(dict)).error(), Error::InvalidProgram); + } +} + +class CoreAIManifestMetadataTest : public ::testing::TestWithParam { + protected: + NSMutableDictionary* manifest() { + return GetParam() ? aot_manifest_dict() : manifest_dict(); + } + NSString* file() { + return GetParam() ? @"model.arch_b.aimodelc/graph.bin" : @"model.aimodel/graph.bin"; + } +}; + +TEST_P(CoreAIManifestMetadataTest, RequiresExactBundleCoverage) { + for (bool extra : {false, true}) { + SCOPED_TRACE(extra ? "extra digest" : "missing digest"); + auto dict = manifest(); + NSMutableDictionary* digests = [dict[@"bundle_digests"] mutableCopy]; + if (extra) { + digests[@"unexpected.aimodel"] = fixture_identity('e'); + } else { + [digests removeObjectForKey:file().stringByDeletingLastPathComponent]; + } + dict[@"bundle_digests"] = digests; + EXPECT_EQ(parse_manifest(encode(dict)).error(), Error::InvalidProgram); + } +} + +INSTANTIATE_TEST_SUITE_P( + Delivery, CoreAIManifestMetadataTest, ::testing::Bool(), + [](const ::testing::TestParamInfo& info) { + return info.param ? "Aot" : "Source"; + }); + +TEST(CoreAIManifestTest, SelectsAotArchitectureWithoutMutatingManifest) { + auto dict = aot_manifest_dict(); + auto parsed = parse_manifest(encode(dict)); + ASSERT_TRUE(parsed.ok()); + ASSERT_TRUE(parsed->aot_compiled); + EXPECT_EQ(parsed->path, nil); + for (NSString* arch in @[ @"arch_a", @"arch_b" ]) { + SCOPED_TRACE(arch.UTF8String); + auto selected = select_assets(parsed.get(), arch, @"macOS"); + ASSERT_TRUE(selected.ok()); + EXPECT_TRUE([selected->path isEqualToString:dict[@"archs"][arch]]); + EXPECT_EQ(selected->files.count, 2); + NSString* bundle = selected->path.lastPathComponent; + EXPECT_TRUE(([selected->bundle_digests isEqual:@{bundle : dict[@"bundle_digests"][bundle]}])); + EXPECT_TRUE(([selected->files isEqual:@{ + [bundle stringByAppendingPathComponent:@"graph.bin"] : @14, + [bundle stringByAppendingPathComponent:@"nested/weights.bin"] : @7 + }])); + EXPECT_EQ(parsed->path, nil); + } +} + +TEST(CoreAIManifestTest, RejectsMismatchedAotPlatformAndArchitecture) { + auto ios = aot_manifest_dict(); + ios[@"platform"] = @"iOS"; + auto parsed_ios = parse_manifest(encode(ios)); + ASSERT_TRUE(parsed_ios.ok()); + EXPECT_TRUE(select_assets(parsed_ios.get(), @"arch_b", @"iOS").ok()); + auto parsed = parse_manifest(encode(aot_manifest_dict())); + ASSERT_TRUE(parsed.ok()); + EXPECT_EQ(select_assets(parsed.get(), @"arch_b", @"iOS").error(), + Error::DelegateInvalidCompatibility); + for (id arch in @[ @"missing", @"ARCH_B", @"", NSNull.null ]) { + SCOPED_TRACE([arch description].UTF8String); + EXPECT_EQ(select_assets(parsed.get(), arch == NSNull.null ? nil : arch, @"macOS").error(), + Error::DelegateInvalidCompatibility); + } +} diff --git a/tools/cmake/preset/default.cmake b/tools/cmake/preset/default.cmake index 039c5ce3712..16e2b574d0e 100644 --- a/tools/cmake/preset/default.cmake +++ b/tools/cmake/preset/default.cmake @@ -23,6 +23,9 @@ define_overridable_option( define_overridable_option( EXECUTORCH_BUILD_COREML "Build the Core ML backend" BOOL OFF ) +define_overridable_option( + EXECUTORCH_BUILD_COREAI "Build the Core AI backend" BOOL OFF +) define_overridable_option( EXECUTORCH_FLATBUFFERS_MAX_ALIGNMENT "Exir lets users set the alignment of tensor data embedded in the flatbuffer, and some users need an alignment larger than the default, which is typically 32." From 5f68c119164cbe7bf6203fa86ab69e2a63690358 Mon Sep 17 00:00:00 2001 From: Scott Roy Date: Mon, 26 Oct 2026 13:21:05 -0700 Subject: [PATCH 2/4] [CoreAI] Address manifest parser review feedback Align the parser-only PR with its actual supported behavior and cover malformed manifest metadata before later storage and loading work depends on it. Test Plan: Built and ran the native host suite on macOS 27: all 24 tests passed. CMake platform/test gating and workflow formatting-config checks passed. The Python suite was blocked during collection by the installed PyTorch/torchao compatibility error. Authored with assistance from Devmate (OpenAI GPT-6-Astra). --- .github/workflows/coreai.yml | 16 +++ backends/apple/coreai/CMakeLists.txt | 8 +- backends/apple/coreai/README.md | 21 ++-- .../apple/coreai/runtime/coreai_assets.mm | 6 -- .../runtime/test/coreai_manifest_test.mm | 102 ++++++++++++++++++ 5 files changed, 130 insertions(+), 23 deletions(-) diff --git a/.github/workflows/coreai.yml b/.github/workflows/coreai.yml index e3b9a8542f6..f415089b293 100644 --- a/.github/workflows/coreai.yml +++ b/.github/workflows/coreai.yml @@ -10,7 +10,11 @@ on: pull_request: paths: - .github/workflows/coreai.yml + - .clang-format + - CMakeLists.txt - backends/apple/coreai/** + - runtime/** + - tools/cmake/** workflow_dispatch: concurrency: @@ -63,6 +67,18 @@ jobs: ${CONDA_RUN} pip list echo "::endgroup::" + echo "::group::Check Core AI formatting configuration" + ${CONDA_RUN} python - <<'PY' + from pathlib import Path + import yaml + + root = yaml.safe_load(Path(".clang-format").read_text()) + coreai = yaml.safe_load(Path("backends/apple/coreai/.clang-format").read_text()) + root.pop("Language") + assert coreai == root, "Sync the Core AI clang-format configuration with the root style (without Language)" + PY + echo "::endgroup::" + echo "::group::Run Core AI export tests" ${CONDA_RUN} python -m pytest backends/apple/coreai echo "::endgroup::" diff --git a/backends/apple/coreai/CMakeLists.txt b/backends/apple/coreai/CMakeLists.txt index 312f30c9d0c..be160d53952 100644 --- a/backends/apple/coreai/CMakeLists.txt +++ b/backends/apple/coreai/CMakeLists.txt @@ -5,7 +5,7 @@ # LICENSE file in the root directory of this source tree. if(NOT APPLE) - message(FATAL_ERROR "Core AI host tests require Apple platforms") + message(FATAL_ERROR "Core AI requires Apple platforms") endif() enable_language(OBJCXX) @@ -25,17 +25,13 @@ function(coreai_configure_objc_target target) ) endfunction() -if(EXECUTORCH_BUILD_TESTS) - if(NOT CMAKE_SYSTEM_NAME STREQUAL "Darwin") - message(FATAL_ERROR "Core AI host tests require a macOS build") - endif() +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} ) 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 99ca602bbdd..ba384103f78 100644 --- a/backends/apple/coreai/README.md +++ b/backends/apple/coreai/README.md @@ -44,20 +44,19 @@ These are Core AI architecture names, not CPU names such as `arm64`. ## Host Tests -`EXECUTORCH_BUILD_COREAI=ON` with `EXECUTORCH_BUILD_TESTS=ON` registers the -standalone `coreai_host_test` executable with CTest. It compiles the runtime -sources without Swift or CoreAI, routing any SDK calls through a fake bridge, so -it also runs on macOS 26. Host tests do not establish real SDK behavior. +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. -From the repository root: +CI builds and runs these tests on macOS 27. To use the same configuration from +the repository root: ```bash cmake -S . -B -G Ninja \ - -DCMAKE_BUILD_TYPE=Release -DCMAKE_OSX_DEPLOYMENT_TARGET=26.0 \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_OSX_DEPLOYMENT_TARGET=27.0 \ -DEXECUTORCH_BUILD_COREAI=ON -DEXECUTORCH_BUILD_TESTS=ON \ - -DEXECUTORCH_BUILD_EXTENSION_DATA_LOADER=ON \ - -DEXECUTORCH_ENABLE_PROGRAM_VERIFICATION=ON \ - -DEXECUTORCH_BUILD_EXECUTOR_RUNNER=OFF -cmake --build --target coreai_host_test -ctest --test-dir -R '^coreai_host_test$' --output-on-failure + -DEXECUTORCH_BUILD_EXTENSION_DATA_LOADER=ON +cmake --build --target backends/apple/coreai/all +ctest --test-dir /backends/apple/coreai --output-on-failure --no-tests=error ``` diff --git a/backends/apple/coreai/runtime/coreai_assets.mm b/backends/apple/coreai/runtime/coreai_assets.mm index d28cb8e1d55..d10b79dca69 100644 --- a/backends/apple/coreai/runtime/coreai_assets.mm +++ b/backends/apple/coreai/runtime/coreai_assets.mm @@ -280,12 +280,6 @@ bool parse_version(id value, NSOperatingSystemVersion& version) { files[file] = manifest.files[file]; } } - ET_CHECK_OR_RETURN_ERROR( - files.count > 0, - InvalidProgram, - "Core AI manifest has no files for selected device architecture %s; " - "re-export the model", - device_architecture.UTF8String); selected.files = files; selected.bundle_digests = @{ path.lastPathComponent : manifest.bundle_digests[path.lastPathComponent] diff --git a/backends/apple/coreai/runtime/test/coreai_manifest_test.mm b/backends/apple/coreai/runtime/test/coreai_manifest_test.mm index 337a939c4d8..2aa53ffcec8 100644 --- a/backends/apple/coreai/runtime/test/coreai_manifest_test.mm +++ b/backends/apple/coreai/runtime/test/coreai_manifest_test.mm @@ -141,6 +141,108 @@ } } +TEST_P(CoreAIManifestMetadataTest, RejectsInvalidHashes) { + auto missing = manifest(); + [missing removeObjectForKey:@"hash"]; + EXPECT_EQ(parse_manifest(encode(missing)).error(), Error::InvalidProgram); + for (id hash in @[ @"", @"g", @"AB", @"../x", @"a/b", @1, NSNull.null ]) { + SCOPED_TRACE([hash description].UTF8String); + auto dict = manifest(); + dict[@"hash"] = hash; + if (GetParam()) { + dict[@"archs"] = @{ + @"arch_a" : + [NSString stringWithFormat:@"%@/model.arch_a.aimodelc", hash], + @"arch_b" : + [NSString stringWithFormat:@"%@/model.arch_b.aimodelc", hash] + }; + } else { + dict[@"path"] = [NSString stringWithFormat:@"%@/model.aimodel", hash]; + } + EXPECT_EQ(parse_manifest(encode(dict)).error(), Error::InvalidProgram); + } +} + +TEST_P(CoreAIManifestMetadataTest, RejectsFilesForUndeclaredBundles) { + auto dict = manifest(); + NSMutableDictionary* files = [dict[@"files"] mutableCopy]; + files[@"model.undeclared.aimodelc/graph.bin"] = @1; + dict[@"files"] = files; + EXPECT_EQ(parse_manifest(encode(dict)).error(), Error::InvalidProgram); +} + +TEST_P(CoreAIManifestMetadataTest, RequiresFilesForEveryDeclaredBundle) { + auto valid = manifest(); + for (NSString* bundle in valid[@"bundle_digests"]) { + SCOPED_TRACE(bundle.UTF8String); + NSMutableDictionary* dict = [valid mutableCopy]; + NSMutableDictionary* files = [dict[@"files"] mutableCopy]; + NSString* prefix = [bundle stringByAppendingString:@"/"]; + for (NSString* file in dict[@"files"]) { + if ([file hasPrefix:prefix]) { + [files removeObjectForKey:file]; + } + } + dict[@"files"] = files; + EXPECT_EQ(parse_manifest(encode(dict)).error(), Error::InvalidProgram); + } +} + +TEST(CoreAIManifestTest, RequiresAotPlatform) { + auto dict = aot_manifest_dict(); + [dict removeObjectForKey:@"platform"]; + EXPECT_EQ(parse_manifest(encode(dict)).error(), Error::InvalidProgram); + for (id platform in @[ @"", @1, @[], @{}, NSNull.null ]) { + SCOPED_TRACE([platform description].UTF8String); + dict[@"platform"] = platform; + EXPECT_EQ(parse_manifest(encode(dict)).error(), Error::InvalidProgram); + } +} + +TEST(CoreAIManifestTest, RequiresNonemptyAotArchitectureMap) { + auto dict = aot_manifest_dict(); + [dict removeObjectForKey:@"archs"]; + EXPECT_EQ(parse_manifest(encode(dict)).error(), Error::InvalidProgram); + for (id archs in @[ @{}, @[], @"arch_a", @1, NSNull.null ]) { + SCOPED_TRACE([archs description].UTF8String); + dict[@"archs"] = archs; + EXPECT_EQ(parse_manifest(encode(dict)).error(), Error::InvalidProgram); + } +} + +TEST(CoreAIManifestTest, RejectsUnsafeAotArchitectureNames) { + for (NSString* arch in @[ @"", @".", @"..", @"../arch_a", @"a/b", @"a\\b" ]) { + SCOPED_TRACE(arch.UTF8String); + auto dict = aot_manifest_dict(); + NSString* bundle = [NSString stringWithFormat:@"model.%@.aimodelc", arch]; + dict[@"archs"] = @{arch : [@"ab/" stringByAppendingString:bundle]}; + dict[@"files"] = @{[bundle stringByAppendingString:@"/graph.bin"] : @1}; + dict[@"bundle_digests"] = @{bundle : fixture_identity('a')}; + EXPECT_EQ(parse_manifest(encode(dict)).error(), Error::InvalidProgram); + } +} + +TEST(CoreAIManifestTest, RejectsInvalidAotBundlePaths) { + for (id path in @[ + @"", + @"../model.arch_a.aimodelc", + @"/ab/model.arch_a.aimodelc", + @"ab/../model.arch_a.aimodelc", + @"xx/model.arch_a.aimodelc", + @"ab/model.arch_b.aimodelc", + @"ab/model.aimodel", + @1, + NSNull.null + ]) { + SCOPED_TRACE([path description].UTF8String); + auto dict = aot_manifest_dict(); + NSMutableDictionary* archs = [dict[@"archs"] mutableCopy]; + archs[@"arch_a"] = path; + dict[@"archs"] = archs; + EXPECT_EQ(parse_manifest(encode(dict)).error(), Error::InvalidProgram); + } +} + INSTANTIATE_TEST_SUITE_P( Delivery, CoreAIManifestMetadataTest, ::testing::Bool(), [](const ::testing::TestParamInfo& info) { From 57fedbaadadf842bbf261d18c8e2e249a01b2bb9 Mon Sep 17 00:00:00 2001 From: Scott Roy Date: Tue, 27 Oct 2026 11:27:12 -0700 Subject: [PATCH 3/4] [CoreAI] Reject aliased manifest paths and strengthen parser coverage Summary: Reject manifests whose asset names collide on case-insensitive, Unicode-normalizing filesystems before materialization. Handle missing input safely, verify returned metadata and ordering, and enforce Objective-C++ formatting in backend CI. Test Plan: The new filesystem-alias regressions failed before the fix. All 32 native parser tests and all 113 CoreAI Python tests passed afterward, covering source/AOT aliases, valid Unicode names, nil input, returned fields, name ordering, and version parsing. Formatting, workflow syntax, and whitespace checks passed. --- .github/workflows/coreai.yml | 7 +- backends/apple/coreai/README.md | 4 +- .../apple/coreai/runtime/coreai_assets.mm | 26 ++- .../runtime/test/coreai_manifest_test.mm | 167 ++++++++++++++++-- 4 files changed, 181 insertions(+), 23 deletions(-) diff --git a/.github/workflows/coreai.yml b/.github/workflows/coreai.yml index f415089b293..33367a13819 100644 --- a/.github/workflows/coreai.yml +++ b/.github/workflows/coreai.yml @@ -63,19 +63,22 @@ jobs: echo "::group::Install ExecuTorch and Core AI requirements" ${CONDA_RUN} python install_executorch.py --editable > /dev/null - ${CONDA_RUN} pip install -r backends/apple/coreai/requirements.txt "cmake>=3.31,<4" ninja + ${CONDA_RUN} pip install -r backends/apple/coreai/requirements.txt "cmake>=3.31,<4" ninja clang-format==18.1.3 ${CONDA_RUN} pip list echo "::endgroup::" - echo "::group::Check Core AI formatting configuration" + echo "::group::Check Core AI formatting" ${CONDA_RUN} python - <<'PY' from pathlib import Path + import subprocess import yaml root = yaml.safe_load(Path(".clang-format").read_text()) coreai = yaml.safe_load(Path("backends/apple/coreai/.clang-format").read_text()) root.pop("Language") assert coreai == root, "Sync the Core AI clang-format configuration with the root style (without Language)" + sources = sorted(Path("backends/apple/coreai").rglob("*.mm")) + subprocess.run(["clang-format", "--dry-run", "--Werror", *map(str, sources)], check=True) PY echo "::endgroup::" diff --git a/backends/apple/coreai/README.md b/backends/apple/coreai/README.md index ba384103f78..efab5a4ebd3 100644 --- a/backends/apple/coreai/README.md +++ b/backends/apple/coreai/README.md @@ -32,7 +32,9 @@ The delegate manifest describes `.aimodel` source bundles, which require `input_names` and `output_names` matching the converter's bindings. Dictionary iteration order is not a binding contract. The `files` object maps relative filenames to byte sizes; `bundle_digests` maps each bundle basename to its -export-time SHA-256 digest. +export-time SHA-256 digest. Asset paths, including bundle and directory names, +must remain distinct under case-insensitive, canonically normalized Unicode +comparison. Accepted names are preserved, not rewritten. ### AOT architecture selection diff --git a/backends/apple/coreai/runtime/coreai_assets.mm b/backends/apple/coreai/runtime/coreai_assets.mm index d10b79dca69..7e3e7d2694b 100644 --- a/backends/apple/coreai/runtime/coreai_assets.mm +++ b/backends/apple/coreai/runtime/coreai_assets.mm @@ -67,6 +67,9 @@ Error validate_files( "Core AI manifest requires asset file sizes"); NSSet* files = [NSSet setWithArray:filenames.allKeys]; NSMutableSet* populated = [NSMutableSet set]; + NSMutableDictionary* spellings = + [NSMutableDictionary dictionary]; + NSLocale* locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]; for (NSString* file in filenames) { ET_CHECK_OR_RETURN_ERROR( relative_path(file) && file_size(filenames[file]) && @@ -77,13 +80,24 @@ Error validate_files( InvalidProgram, "Invalid Core AI asset path or size"); [populated addObject:[file componentsSeparatedByString:@"/"].firstObject]; - NSString* parent = file.stringByDeletingLastPathComponent; - while (parent.length > 0) { + // Aliased parent directories also merge otherwise distinct file paths. + NSString* path = file; + while (path.length > 0) { + NSString* identity = + [path stringByFoldingWithOptions:NSCaseInsensitiveSearch + locale:locale] + .decomposedStringWithCanonicalMapping; + NSString* previous = spellings[identity]; + ET_CHECK_OR_RETURN_ERROR( + previous == nil || [previous isEqualToString:path], + InvalidProgram, + "Case or Unicode alias in Core AI asset paths"); + spellings[identity] = path; + path = path.stringByDeletingLastPathComponent; ET_CHECK_OR_RETURN_ERROR( - ![files containsObject:parent], + ![files containsObject:path], InvalidProgram, "Conflicting Core AI asset paths"); - parent = parent.stringByDeletingLastPathComponent; } } ET_CHECK_OR_RETURN_ERROR( @@ -124,6 +138,8 @@ bool parse_version(id value, NSOperatingSystemVersion& version) { } // namespace Result parse_manifest(NSData* data) { + ET_CHECK_OR_RETURN_ERROR( + data != nil, InvalidProgram, "Missing Core AI manifest JSON"); NSError* error = nil; id object = [NSJSONSerialization JSONObjectWithData:data options:0 @@ -249,7 +265,7 @@ bool parse_version(id value, NSOperatingSystemVersion& version) { "Core AI AOT target platform %s does not match runtime platform %s; " "re-export the model for this platform", manifest.platform.UTF8String, - platform.UTF8String); + platform == nil ? "nil" : platform.UTF8String); NSArray* architectures = [manifest.archs.allKeys sortedArrayUsingComparator:^NSComparisonResult(NSString* a, NSString* b) { return [a compare:b options:NSLiteralSearch]; diff --git a/backends/apple/coreai/runtime/test/coreai_manifest_test.mm b/backends/apple/coreai/runtime/test/coreai_manifest_test.mm index 2aa53ffcec8..7f66068448d 100644 --- a/backends/apple/coreai/runtime/test/coreai_manifest_test.mm +++ b/backends/apple/coreai/runtime/test/coreai_manifest_test.mm @@ -6,8 +6,8 @@ * LICENSE file in the root directory of this source tree. */ -#include "coreai_manifest_fixture.h" #include +#include "coreai_manifest_fixture.h" using namespace executorch::runtime; using namespace executorch::backends::coreai; @@ -15,8 +15,11 @@ TEST(CoreAIManifestTest, AcceptsInlineAndRejectsInvalidJson) { EXPECT_TRUE(parse_manifest(encode(manifest_dict())).ok()); - EXPECT_FALSE(parse_manifest([@"[]" dataUsingEncoding:NSUTF8StringEncoding]).ok()); - EXPECT_FALSE(parse_manifest([@"garbage" dataUsingEncoding:NSUTF8StringEncoding]).ok()); + EXPECT_EQ(parse_manifest(nil).error(), Error::InvalidProgram); + EXPECT_FALSE( + parse_manifest([@"[]" dataUsingEncoding:NSUTF8StringEncoding]).ok()); + EXPECT_FALSE( + parse_manifest([@"garbage" dataUsingEncoding:NSUTF8StringEncoding]).ok()); } TEST(CoreAIManifestTest, RejectsUnknownPackaging) { @@ -27,7 +30,9 @@ TEST(CoreAIManifestTest, RejectsUnsafeSourcePaths) { for (NSString* path in @[ - @"../model.aimodel", @"/ab/model.aimodel", @"ab/../model.aimodel", + @"../model.aimodel", + @"/ab/model.aimodel", + @"ab/../model.aimodel", @"xx/model.aimodel" ]) { SCOPED_TRACE(path.UTF8String); @@ -39,11 +44,15 @@ TEST(CoreAIManifestTest, RejectsInvalidFileMaps) { for (id files in @[ - @[], @{}, NSNull.null, @"files", + @[], + @{}, + NSNull.null, + @"files", @{@"model.aimodel/../escape" : @1}, @{@"model.aimodel/bad\\name" : @1}, @{@"/tmp/file" : @1}, - @{@"model.aimodel/one" : @1, @"model.aimodel/one/two" : @2} + @{@"model.aimodel/one" : @1, + @"model.aimodel/one/two" : @2} ]) { SCOPED_TRACE([files description].UTF8String); auto dict = manifest_dict(); @@ -53,7 +62,8 @@ } TEST(CoreAIManifestTest, RejectsInvalidInputNames) { - for (id names in @[ @[ @"same", @"same" ], @[ @1 ], @[ @"" ], @"input", NSNull.null ]) { + for (id names in + @[ @[ @"same", @"same" ], @[ @1 ], @[ @"" ], @"input", NSNull.null ]) { SCOPED_TRACE([names description].UTF8String); auto dict = manifest_dict(); dict[@"input_names"] = names; @@ -73,6 +83,27 @@ EXPECT_TRUE(parse_manifest(encode(dict)).ok()); } +TEST(CoreAIManifestTest, ParsesDeploymentVersionComponents) { + const struct { + id value; + NSOperatingSystemVersion expected; + } rows[] = { + {@"26", {26, 0, 0}}, + {@"26.4", {26, 4, 0}}, + {@"26.4.2", {26, 4, 2}}, + {NSNull.null, {0, 0, 0}}}; + for (const auto& row : rows) { + SCOPED_TRACE([row.value description].UTF8String); + auto dict = manifest_dict(); + dict[@"min_deployment_version"] = row.value; + auto parsed = parse_manifest(encode(dict)); + ASSERT_TRUE(parsed.ok()); + EXPECT_EQ(parsed->minimum_version.majorVersion, row.expected.majorVersion); + EXPECT_EQ(parsed->minimum_version.minorVersion, row.expected.minorVersion); + EXPECT_EQ(parsed->minimum_version.patchVersion, row.expected.patchVersion); + } +} + TEST(CoreAIManifestTest, RequiresMetadataMaps) { auto dict = manifest_dict(); ASSERT_TRUE(parse_manifest(encode(dict)).ok()); @@ -106,7 +137,8 @@ TEST(CoreAIManifestTest, RequiresExactLowercaseDigests) { for (NSString* digest in @[ - [fixture_identity('a') substringFromIndex:1], fixture_identity('a').uppercaseString, + [fixture_identity('a') substringFromIndex:1], + fixture_identity('a').uppercaseString, [@"g" stringByPaddingToLength:64 withString:@"g" startingAtIndex:0] ]) { SCOPED_TRACE(digest.UTF8String); @@ -122,10 +154,81 @@ return GetParam() ? aot_manifest_dict() : manifest_dict(); } NSString* file() { - return GetParam() ? @"model.arch_b.aimodelc/graph.bin" : @"model.aimodel/graph.bin"; + return GetParam() ? @"model.arch_b.aimodelc/graph.bin" + : @"model.aimodel/graph.bin"; } }; +TEST_P(CoreAIManifestMetadataTest, ReturnsManifestFieldsAndNameOrder) { + auto dict = manifest(); + dict[@"function"] = @"predict"; + dict[@"input_names"] = @[ @"input_1", @"input_0" ]; + dict[@"output_names"] = @[ @"output_2", @"output_0", @"output_1" ]; + dict[@"min_deployment_version"] = @"26.4.2"; + auto parsed = parse_manifest(encode(dict)); + ASSERT_TRUE(parsed.ok()); + EXPECT_EQ(parsed->aot_compiled, GetParam()); + EXPECT_TRUE([parsed->hash isEqual:dict[@"hash"]]); + EXPECT_TRUE([parsed->function isEqual:dict[@"function"]]); + EXPECT_TRUE([parsed->inputs isEqual:dict[@"input_names"]]); + EXPECT_TRUE([parsed->outputs isEqual:dict[@"output_names"]]); + EXPECT_TRUE([parsed->files isEqual:dict[@"files"]]); + EXPECT_TRUE([parsed->bundle_digests isEqual:dict[@"bundle_digests"]]); + EXPECT_EQ(parsed->minimum_version.majorVersion, 26); + EXPECT_EQ(parsed->minimum_version.minorVersion, 4); + EXPECT_EQ(parsed->minimum_version.patchVersion, 2); + if (GetParam()) { + EXPECT_EQ(parsed->path, nil); + EXPECT_TRUE([parsed->platform isEqual:dict[@"platform"]]); + EXPECT_TRUE([parsed->archs isEqual:dict[@"archs"]]); + } else { + EXPECT_TRUE([parsed->path isEqual:dict[@"path"]]); + EXPECT_EQ(parsed->platform, nil); + EXPECT_EQ(parsed->archs, nil); + } +} + +TEST_P(CoreAIManifestMetadataTest, RejectsFilesystemAliases) { + NSString* composed = [NSString stringWithFormat:@"caf%C", 0x00e9]; + NSString* decomposed = composed.decomposedStringWithCanonicalMapping; + for (NSArray* pair in @[ + @[ @"One", @"one" ], + @[ @"One", @"one/two" ], + @[ @"Dir/a", @"dir/b" ], + @[ composed, decomposed ], + @[ composed, [decomposed stringByAppendingPathComponent:@"child"] ], + @[ + [composed stringByAppendingPathComponent:@"a"], + [decomposed stringByAppendingPathComponent:@"b"] + ], + @[ composed.uppercaseString, decomposed ] + ]) { + SCOPED_TRACE(pair.description.UTF8String); + auto dict = manifest(); + NSMutableDictionary* files = [dict[@"files"] mutableCopy]; + NSString* bundle = file().stringByDeletingLastPathComponent; + files[[bundle stringByAppendingPathComponent:pair[0]]] = @1; + files[[bundle stringByAppendingPathComponent:pair[1]]] = @2; + dict[@"files"] = files; + EXPECT_EQ(parse_manifest(encode(dict)).error(), Error::InvalidProgram); + } +} + +TEST_P(CoreAIManifestMetadataTest, PreservesDistinctUnicodePaths) { + auto dict = manifest(); + NSMutableDictionary* files = [dict[@"files"] mutableCopy]; + NSString* bundle = file().stringByDeletingLastPathComponent; + NSString* composed = [NSString stringWithFormat:@"caf%C", 0x00e9]; + for (NSString* name in + @[ composed, @"same/one", @"same/two", @"other/ONE" ]) { + files[[bundle stringByAppendingPathComponent:name]] = @1; + } + dict[@"files"] = files; + auto parsed = parse_manifest(encode(dict)); + ASSERT_TRUE(parsed.ok()); + EXPECT_TRUE([parsed->files isEqual:files]); +} + TEST_P(CoreAIManifestMetadataTest, RequiresExactBundleCoverage) { for (bool extra : {false, true}) { SCOPED_TRACE(extra ? "extra digest" : "missing digest"); @@ -222,6 +325,31 @@ } } +TEST(CoreAIManifestTest, RejectsAliasedAotBundles) { + NSString* composed = [NSString stringWithFormat:@"arch_%C", 0x00e9]; + for (NSArray* pair in @[ + @[ @"arch_a", @"ARCH_A" ], + @[ composed, composed.decomposedStringWithCanonicalMapping ] + ]) { + SCOPED_TRACE(pair.description.UTF8String); + auto dict = aot_manifest_dict(); + NSMutableDictionary* archs = [NSMutableDictionary dictionary]; + NSMutableDictionary* files = [NSMutableDictionary dictionary]; + NSMutableDictionary* digests = [NSMutableDictionary dictionary]; + for (NSString* arch in pair) { + NSString* bundle = [NSString stringWithFormat:@"model.%@.aimodelc", arch]; + archs[arch] = [@"ab" stringByAppendingPathComponent:bundle]; + files[[bundle stringByAppendingPathComponent:@"graph.bin"]] = @1; + digests[bundle] = fixture_identity('a'); + } + ASSERT_EQ(archs.count, 2); + dict[@"archs"] = archs; + dict[@"files"] = files; + dict[@"bundle_digests"] = digests; + EXPECT_EQ(parse_manifest(encode(dict)).error(), Error::InvalidProgram); + } +} + TEST(CoreAIManifestTest, RejectsInvalidAotBundlePaths) { for (id path in @[ @"", @@ -244,7 +372,9 @@ } INSTANTIATE_TEST_SUITE_P( - Delivery, CoreAIManifestMetadataTest, ::testing::Bool(), + Delivery, + CoreAIManifestMetadataTest, + ::testing::Bool(), [](const ::testing::TestParamInfo& info) { return info.param ? "Aot" : "Source"; }); @@ -262,7 +392,8 @@ EXPECT_TRUE([selected->path isEqualToString:dict[@"archs"][arch]]); EXPECT_EQ(selected->files.count, 2); NSString* bundle = selected->path.lastPathComponent; - EXPECT_TRUE(([selected->bundle_digests isEqual:@{bundle : dict[@"bundle_digests"][bundle]}])); + EXPECT_TRUE(([selected->bundle_digests + isEqual:@{bundle : dict[@"bundle_digests"][bundle]}])); EXPECT_TRUE(([selected->files isEqual:@{ [bundle stringByAppendingPathComponent:@"graph.bin"] : @14, [bundle stringByAppendingPathComponent:@"nested/weights.bin"] : @7 @@ -279,11 +410,17 @@ EXPECT_TRUE(select_assets(parsed_ios.get(), @"arch_b", @"iOS").ok()); auto parsed = parse_manifest(encode(aot_manifest_dict())); ASSERT_TRUE(parsed.ok()); - EXPECT_EQ(select_assets(parsed.get(), @"arch_b", @"iOS").error(), - Error::DelegateInvalidCompatibility); + EXPECT_EQ( + select_assets(parsed.get(), @"arch_b", @"iOS").error(), + Error::DelegateInvalidCompatibility); + EXPECT_EQ( + select_assets(parsed.get(), @"arch_b", nil).error(), + Error::DelegateInvalidCompatibility); for (id arch in @[ @"missing", @"ARCH_B", @"", NSNull.null ]) { SCOPED_TRACE([arch description].UTF8String); - EXPECT_EQ(select_assets(parsed.get(), arch == NSNull.null ? nil : arch, @"macOS").error(), - Error::DelegateInvalidCompatibility); + EXPECT_EQ( + select_assets(parsed.get(), arch == NSNull.null ? nil : arch, @"macOS") + .error(), + Error::DelegateInvalidCompatibility); } } From 6f36d09942f24b27d04a4fea234abdeb459f6f5b Mon Sep 17 00:00:00 2001 From: Scott Roy Date: Thu, 29 Oct 2026 13:07:34 -0700 Subject: [PATCH 4/4] [CoreAI] Separate validated manifest parsing and bundle selection Make parsed metadata and selected bundles immutable so callers cannot bypass validation or confuse unselected exports with materializable bundles. Keep pure selection independent of filesystem and SDK concerns, and share distinct text/path/hex predicates without changing their rules. Test Plan: 113 CoreAI Python export tests passed. Built coreai_host_test with Xcode 27 and ran all 36 tests, including immutable-type, raw-buffer ownership, source/AOT selection and embedded-NUL coverage. CTest and scoped lint passed. Existing native CI jobs fail because their native test script is absent from the base tree; no native infrastructure changes included. Generic Buck validation is unavailable for this checkout. --- backends/apple/coreai/CMakeLists.txt | 4 +- backends/apple/coreai/README.md | 6 + backends/apple/coreai/runtime/coreai_assets.h | 37 ---- .../apple/coreai/runtime/coreai_manifest.h | 87 ++++++++ .../{coreai_assets.mm => coreai_manifest.mm} | 185 ++++++++++-------- .../apple/coreai/runtime/coreai_validation.h | 24 +++ .../apple/coreai/runtime/coreai_validation.mm | 51 +++++ .../runtime/test/coreai_manifest_fixture.h | 2 +- .../runtime/test/coreai_manifest_test.mm | 112 ++++++++++- 9 files changed, 376 insertions(+), 132 deletions(-) delete mode 100644 backends/apple/coreai/runtime/coreai_assets.h create mode 100644 backends/apple/coreai/runtime/coreai_manifest.h rename backends/apple/coreai/runtime/{coreai_assets.mm => coreai_manifest.mm} (68%) create mode 100644 backends/apple/coreai/runtime/coreai_validation.h create mode 100644 backends/apple/coreai/runtime/coreai_validation.mm diff --git a/backends/apple/coreai/CMakeLists.txt b/backends/apple/coreai/CMakeLists.txt index be160d53952..800a9f098a4 100644 --- a/backends/apple/coreai/CMakeLists.txt +++ b/backends/apple/coreai/CMakeLists.txt @@ -10,7 +10,9 @@ endif() enable_language(OBJCXX) -set(_coreai_runtime_sources runtime/coreai_assets.mm) +set(_coreai_runtime_sources runtime/coreai_manifest.mm + runtime/coreai_validation.mm +) function(coreai_configure_objc_target target) target_include_directories( diff --git a/backends/apple/coreai/README.md b/backends/apple/coreai/README.md index efab5a4ebd3..4b5115e1a21 100644 --- a/backends/apple/coreai/README.md +++ b/backends/apple/coreai/README.md @@ -36,6 +36,12 @@ export-time SHA-256 digest. Asset paths, including bundle and directory names, must remain distinct under case-insensitive, canonically normalized Unicode comparison. Accepted names are preserved, not rewritten. +Parsing returns an immutable `Manifest` covering all exported bundles. Pure +platform and architecture selection produces an immutable `SelectedBundle` +with one bundle's path, digest, and files. Neither type can be constructed +without validation; filesystem materialization and SDK host probing remain +outside the manifest parser. + ### AOT architecture selection Configure AOT export with `AOTCompileConfig`, including the target `platform` and diff --git a/backends/apple/coreai/runtime/coreai_assets.h b/backends/apple/coreai/runtime/coreai_assets.h deleted file mode 100644 index ebf84a3bf01..00000000000 --- a/backends/apple/coreai/runtime/coreai_assets.h +++ /dev/null @@ -1,37 +0,0 @@ -/* - * 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 { - -struct Manifest { - NSString* hash = nil; - NSString* path = nil; - NSString* function = nil; - NSArray* inputs = nil; - NSArray* outputs = nil; - NSDictionary* files = nil; - NSDictionary* bundle_digests = nil; - bool aot_compiled = false; - NSString* platform = nil; - NSDictionary* archs = nil; - NSOperatingSystemVersion minimum_version{}; -}; - -runtime::Result parse_manifest(NSData* data); -// Resolve an already validated manifest before reading or materializing assets. -runtime::Result select_assets( - const Manifest& manifest, - NSString* device_architecture, - NSString* platform); - -} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_manifest.h b/backends/apple/coreai/runtime/coreai_manifest.h new file mode 100644 index 00000000000..c929e9cf632 --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_manifest.h @@ -0,0 +1,87 @@ +/* + * 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 { + +class Manifest final { + public: + NSString* const model_hash; + NSString* const function; + NSArray* const inputs; + NSArray* const outputs; + NSDictionary* const files; + NSDictionary* const bundle_digests; + const bool aot_compiled; + // Source bundles do not constrain the platform or architecture. + NSString* const platform; + NSDictionary* const architectures; + const NSOperatingSystemVersion minimum_version; + + Manifest(const Manifest&) = default; + Manifest(Manifest&&) = default; + Manifest& operator=(const Manifest&) = delete; + Manifest& operator=(Manifest&&) = delete; + + private: + Manifest( + NSString* model_hash, + NSString* function, + NSArray* inputs, + NSArray* outputs, + NSDictionary* files, + NSDictionary* bundle_digests, + bool aot_compiled, + NSString* platform, + NSDictionary* architectures, + NSOperatingSystemVersion minimum_version); + + friend runtime::Result parse_manifest(NSData* data); +}; + +class SelectedBundle final { + public: + NSString* const model_hash; + NSString* const path; + NSString* const bundle_name; + NSString* const digest; + NSString* const function; + NSArray* const inputs; + NSArray* const outputs; + NSDictionary* const files; + const bool aot_compiled; + + SelectedBundle(const SelectedBundle&) = default; + SelectedBundle(SelectedBundle&&) = default; + SelectedBundle& operator=(const SelectedBundle&) = delete; + SelectedBundle& operator=(SelectedBundle&&) = delete; + + private: + SelectedBundle( + const Manifest& manifest, + NSString* path, + NSDictionary* files); + + friend runtime::Result select_assets( + const Manifest& manifest, + NSString* device_architecture, + NSString* platform); +}; + +runtime::Result parse_manifest(NSData* data); +runtime::Result parse_manifest(const void* data, size_t size); +runtime::Result select_assets( + const Manifest& manifest, + NSString* device_architecture, + NSString* platform); + +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_assets.mm b/backends/apple/coreai/runtime/coreai_manifest.mm similarity index 68% rename from backends/apple/coreai/runtime/coreai_assets.mm rename to backends/apple/coreai/runtime/coreai_manifest.mm index 7e3e7d2694b..06ffff5e832 100644 --- a/backends/apple/coreai/runtime/coreai_assets.mm +++ b/backends/apple/coreai/runtime/coreai_manifest.mm @@ -6,20 +6,53 @@ * LICENSE file in the root directory of this source tree. */ -#import "coreai_assets.h" +#import "coreai_manifest.h" #include #include +#import "coreai_validation.h" namespace executorch::backends::coreai { + +Manifest::Manifest( + NSString* model_hash, + NSString* function, + NSArray* inputs, + NSArray* outputs, + NSDictionary* files, + NSDictionary* bundle_digests, + bool aot_compiled, + NSString* platform, + NSDictionary* architectures, + NSOperatingSystemVersion minimum_version) + : model_hash(model_hash), + function(function), + inputs(inputs), + outputs(outputs), + files(files), + bundle_digests(bundle_digests), + aot_compiled(aot_compiled), + platform(platform), + architectures(architectures), + minimum_version(minimum_version) {} + +SelectedBundle::SelectedBundle( + const Manifest& manifest, + NSString* path, + NSDictionary* files) + : model_hash(manifest.model_hash), + path(path), + bundle_name(path.lastPathComponent), + digest(manifest.bundle_digests[bundle_name]), + function(manifest.function), + inputs(manifest.inputs), + outputs(manifest.outputs), + files([files copy]), + aot_compiled(manifest.aot_compiled) {} + namespace { using runtime::Error; using runtime::Result; -bool nonempty_string(id value) { - return [value isKindOfClass:NSString.class] && [value length] > 0 && - [value rangeOfString:@"\0"].location == NSNotFound; -} - bool names_array(id value) { if (![value isKindOfClass:NSArray.class]) { return false; @@ -34,20 +67,6 @@ bool names_array(id value) { return true; } -bool relative_path(NSString* path) { - if (!nonempty_string(path) || path.isAbsolutePath || - [path containsString:@"\\"]) { - return false; - } - for (NSString* component in [path componentsSeparatedByString:@"/"]) { - if (component.length == 0 || [component isEqualToString:@"."] || - [component isEqualToString:@".."]) { - return false; - } - } - return true; -} - bool file_size(id value) { return [value isKindOfClass:NSNumber.class] && CFGetTypeID((__bridge CFTypeRef)value) != CFBooleanGetTypeID() && @@ -159,105 +178,109 @@ bool parse_version(id value, NSOperatingSystemVersion& version) { [packaging isEqualToString:@"aot_compiled_inline"], NotSupported, "Unsupported Core AI packaging; expected .aimodel or AOT .aimodelc"); - Manifest manifest; - manifest.aot_compiled = [packaging hasPrefix:@"aot_compiled_"]; - manifest.hash = dict[@"hash"]; - manifest.path = dict[@"path"]; - manifest.function = dict[@"function"]; - manifest.inputs = dict[@"input_names"]; - manifest.outputs = dict[@"output_names"]; + const bool aot_compiled = [packaging hasPrefix:@"aot_compiled_"]; + NSString* model_hash = dict[@"hash"]; + NSString* function = dict[@"function"]; + NSArray* inputs = dict[@"input_names"]; + NSArray* outputs = dict[@"output_names"]; + NSString* platform = nil; + NSDictionary* architectures = nil; ET_CHECK_OR_RETURN_ERROR( - nonempty_string(manifest.hash) && - [manifest.hash - rangeOfCharacterFromSet:[[NSCharacterSet - characterSetWithCharactersInString: - @"0123456789abcdef"] invertedSet]] - .location == NSNotFound, - InvalidProgram, - "Invalid Core AI model hash"); + lowercase_hex(model_hash), InvalidProgram, "Invalid Core AI model hash"); NSMutableSet* bundles = [NSMutableSet set]; - if (manifest.aot_compiled) { - manifest.platform = dict[@"platform"]; - manifest.archs = dict[@"archs"]; + NSString* source_path = nil; + if (aot_compiled) { + platform = dict[@"platform"]; + architectures = dict[@"archs"]; ET_CHECK_OR_RETURN_ERROR( - nonempty_string(manifest.platform), + nonempty_string(platform), InvalidProgram, - "Core AI AOT manifest requires a target platform; re-export the model"); + "Core AI AOT manifest requires a target platform"); ET_CHECK_OR_RETURN_ERROR( - [manifest.archs isKindOfClass:NSDictionary.class] && - manifest.archs.count > 0, + [architectures isKindOfClass:NSDictionary.class] && + architectures.count > 0, InvalidProgram, - "Core AI AOT manifest requires a nonempty architecture map; " - "re-export the model"); - for (id arch in manifest.archs) { + "Core AI AOT manifest requires a nonempty architecture map"); + for (id arch in architectures) { ET_CHECK_OR_RETURN_ERROR( relative_path(arch) && ![arch containsString:@"/"] && [arch canBeConvertedToEncoding:NSUTF8StringEncoding], InvalidProgram, "Invalid Core AI AOT architecture name"); NSString* bundle = [NSString stringWithFormat:@"model.%@.aimodelc", arch]; - id path = manifest.archs[arch]; + id path = architectures[arch]; ET_CHECK_OR_RETURN_ERROR( relative_path(path) && - [path isEqualToString:[manifest.hash + [path isEqualToString:[model_hash stringByAppendingPathComponent:bundle]], InvalidProgram, "Invalid Core AI AOT bundle path for architecture %s", [arch UTF8String]); [bundles addObject:bundle]; } - manifest.path = nil; } else { + source_path = dict[@"path"]; ET_CHECK_OR_RETURN_ERROR( - relative_path(manifest.path) && - [manifest.path - isEqualToString:[manifest.hash + relative_path(source_path) && + [source_path + isEqualToString:[model_hash stringByAppendingString:@"/model.aimodel"]], InvalidProgram, "Invalid Core AI source bundle path"); [bundles addObject:@"model.aimodel"]; } ET_CHECK_OR_RETURN_ERROR( - nonempty_string(manifest.function) && names_array(manifest.inputs) && - names_array(manifest.outputs), + nonempty_string(function) && names_array(inputs) && names_array(outputs), InvalidProgram, - "Core AI manifest requires ordered input/output names; re-export the " - "model"); + "Core AI manifest requires ordered input/output names"); + NSOperatingSystemVersion minimum_version{}; ET_CHECK_OR_RETURN_ERROR( - parse_version(dict[@"min_deployment_version"], manifest.minimum_version), + parse_version(dict[@"min_deployment_version"], minimum_version), InvalidProgram, "Invalid Core AI minimum deployment version"); - manifest.files = dict[@"files"]; - ET_CHECK_OK_OR_RETURN_ERROR(validate_files(manifest.files, bundles)); - manifest.bundle_digests = dict[@"bundle_digests"]; + NSDictionary* files = dict[@"files"]; + ET_CHECK_OK_OR_RETURN_ERROR(validate_files(files, bundles)); + NSDictionary* bundle_digests = dict[@"bundle_digests"]; ET_CHECK_OR_RETURN_ERROR( - [manifest.bundle_digests isKindOfClass:NSDictionary.class] && - [[NSSet setWithArray:manifest.bundle_digests.allKeys] - isEqualToSet:bundles], + [bundle_digests isKindOfClass:NSDictionary.class] && + [[NSSet setWithArray:bundle_digests.allKeys] isEqualToSet:bundles], InvalidProgram, "Core AI bundle digests must exactly cover declared bundles"); for (NSString* bundle in bundles) { - id digest = manifest.bundle_digests[bundle]; + id digest = bundle_digests[bundle]; ET_CHECK_OR_RETURN_ERROR( - nonempty_string(digest) && [digest length] == 64 && - [digest - rangeOfCharacterFromSet: - [[NSCharacterSet - characterSetWithCharactersInString:@"0123456789abcdef"] - invertedSet]] - .location == NSNotFound, + lowercase_hex(digest, 64), InvalidProgram, "Invalid Core AI exported bundle digest"); } - return manifest; + return Manifest( + model_hash, + function, + inputs, + outputs, + files, + bundle_digests, + aot_compiled, + platform, + architectures, + minimum_version); +} + +Result parse_manifest(const void* data, size_t size) { + ET_CHECK_OR_RETURN_ERROR( + data != nullptr, InvalidProgram, "Missing Core AI manifest JSON"); + return parse_manifest([NSData dataWithBytes:data length:size]); } -Result select_assets( +Result select_assets( const Manifest& manifest, NSString* device_architecture, NSString* platform) { if (!manifest.aot_compiled) { - return manifest; + return SelectedBundle( + manifest, + [manifest.model_hash stringByAppendingString:@"/model.aimodel"], + manifest.files); } ET_CHECK_OR_RETURN_ERROR( [manifest.platform isEqualToString:platform], @@ -266,7 +289,7 @@ bool parse_version(id value, NSOperatingSystemVersion& version) { "re-export the model for this platform", manifest.platform.UTF8String, platform == nil ? "nil" : platform.UTF8String); - NSArray* architectures = [manifest.archs.allKeys + NSArray* architectures = [manifest.architectures.allKeys sortedArrayUsingComparator:^NSComparisonResult(NSString* a, NSString* b) { return [a compare:b options:NSLiteralSearch]; }]; @@ -278,7 +301,7 @@ bool parse_version(id value, NSOperatingSystemVersion& version) { "architectures: [%s]; re-export the model as .aimodel or for this device", device_architecture == nil ? "nil" : device_architecture.UTF8String, available.UTF8String); - NSString* path = manifest.archs[device_architecture]; + NSString* path = manifest.architectures[device_architecture]; ET_CHECK_OR_RETURN_ERROR( path != nil, DelegateInvalidCompatibility, @@ -286,21 +309,15 @@ bool parse_version(id value, NSOperatingSystemVersion& version) { "architectures: [%s]; re-export the model for this device architecture", device_architecture.UTF8String, available.UTF8String); - Manifest selected = manifest; - selected.path = path; NSString* prefix = [path.lastPathComponent stringByAppendingString:@"/"]; - NSMutableDictionary* files = + NSMutableDictionary* selected_files = [NSMutableDictionary dictionary]; for (NSString* file in manifest.files) { if ([file hasPrefix:prefix]) { - files[file] = manifest.files[file]; + selected_files[file] = manifest.files[file]; } } - selected.files = files; - selected.bundle_digests = @{ - path.lastPathComponent : manifest.bundle_digests[path.lastPathComponent] - }; - return selected; + return SelectedBundle(manifest, path, selected_files); } } // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_validation.h b/backends/apple/coreai/runtime/coreai_validation.h new file mode 100644 index 00000000000..a3d060857dc --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_validation.h @@ -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. + */ + +#pragma once + +#import + +namespace executorch::backends::coreai { + +// Nonempty text without embedded NULs. +bool nonempty_string(id value); +// Relative bundle path without escapes, backslashes, or empty components. +bool relative_path(NSString* path); +// 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. +bool lowercase_hex(NSString* value, NSUInteger length); + +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_validation.mm b/backends/apple/coreai/runtime/coreai_validation.mm new file mode 100644 index 00000000000..ea8b018f38f --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_validation.mm @@ -0,0 +1,51 @@ +/* + * 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_validation.h" + +namespace executorch::backends::coreai { +namespace { + +NSCharacterSet* invalid_hex_characters() { + static NSCharacterSet* invalid = [[NSCharacterSet + characterSetWithCharactersInString:@"0123456789abcdef"] invertedSet]; + return invalid; +} + +} // namespace + +bool nonempty_string(id value) { + return [value isKindOfClass:NSString.class] && [value length] > 0 && + [value rangeOfString:@"\0"].location == NSNotFound; +} + +bool relative_path(NSString* path) { + if (!nonempty_string(path) || path.isAbsolutePath || + [path containsString:@"\\"]) { + return false; + } + for (NSString* component in [path componentsSeparatedByString:@"/"]) { + if (component.length == 0 || [component isEqualToString:@"."] || + [component isEqualToString:@".."]) { + return false; + } + } + return true; +} + +bool lowercase_hex(NSString* value) { + return nonempty_string(value) && + [value rangeOfCharacterFromSet:invalid_hex_characters()].location == + NSNotFound; +} + +bool lowercase_hex(NSString* value, NSUInteger length) { + return lowercase_hex(value) && [value length] == length; +} + +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/test/coreai_manifest_fixture.h b/backends/apple/coreai/runtime/test/coreai_manifest_fixture.h index 5155ed8dd77..68223c980a9 100644 --- a/backends/apple/coreai/runtime/test/coreai_manifest_fixture.h +++ b/backends/apple/coreai/runtime/test/coreai_manifest_fixture.h @@ -10,7 +10,7 @@ #include #include -#import "coreai_assets.h" +#import "coreai_manifest.h" namespace executorch::backends::coreai::testing { diff --git a/backends/apple/coreai/runtime/test/coreai_manifest_test.mm b/backends/apple/coreai/runtime/test/coreai_manifest_test.mm index 7f66068448d..cd7d2d22f65 100644 --- a/backends/apple/coreai/runtime/test/coreai_manifest_test.mm +++ b/backends/apple/coreai/runtime/test/coreai_manifest_test.mm @@ -7,7 +7,9 @@ */ #include +#include #include "coreai_manifest_fixture.h" +#include "coreai_validation.h" using namespace executorch::runtime; using namespace executorch::backends::coreai; @@ -168,7 +170,7 @@ auto parsed = parse_manifest(encode(dict)); ASSERT_TRUE(parsed.ok()); EXPECT_EQ(parsed->aot_compiled, GetParam()); - EXPECT_TRUE([parsed->hash isEqual:dict[@"hash"]]); + EXPECT_TRUE([parsed->model_hash isEqual:dict[@"hash"]]); EXPECT_TRUE([parsed->function isEqual:dict[@"function"]]); EXPECT_TRUE([parsed->inputs isEqual:dict[@"input_names"]]); EXPECT_TRUE([parsed->outputs isEqual:dict[@"output_names"]]); @@ -178,13 +180,11 @@ EXPECT_EQ(parsed->minimum_version.minorVersion, 4); EXPECT_EQ(parsed->minimum_version.patchVersion, 2); if (GetParam()) { - EXPECT_EQ(parsed->path, nil); + EXPECT_TRUE([parsed->architectures isEqual:dict[@"archs"]]); EXPECT_TRUE([parsed->platform isEqual:dict[@"platform"]]); - EXPECT_TRUE([parsed->archs isEqual:dict[@"archs"]]); } else { - EXPECT_TRUE([parsed->path isEqual:dict[@"path"]]); + EXPECT_EQ(parsed->architectures, nil); EXPECT_EQ(parsed->platform, nil); - EXPECT_EQ(parsed->archs, nil); } } @@ -384,7 +384,11 @@ auto parsed = parse_manifest(encode(dict)); ASSERT_TRUE(parsed.ok()); ASSERT_TRUE(parsed->aot_compiled); - EXPECT_EQ(parsed->path, nil); + NSDictionary* files_snapshot = [parsed->files copy]; + NSDictionary* digests_snapshot = + [parsed->bundle_digests copy]; + NSDictionary* architectures_snapshot = + [parsed->architectures copy]; for (NSString* arch in @[ @"arch_a", @"arch_b" ]) { SCOPED_TRACE(arch.UTF8String); auto selected = select_assets(parsed.get(), arch, @"macOS"); @@ -392,16 +396,106 @@ EXPECT_TRUE([selected->path isEqualToString:dict[@"archs"][arch]]); EXPECT_EQ(selected->files.count, 2); NSString* bundle = selected->path.lastPathComponent; - EXPECT_TRUE(([selected->bundle_digests - isEqual:@{bundle : dict[@"bundle_digests"][bundle]}])); + EXPECT_TRUE([selected->model_hash isEqual:parsed->model_hash]); + EXPECT_TRUE([selected->function isEqual:parsed->function]); + EXPECT_TRUE([selected->inputs isEqual:parsed->inputs]); + EXPECT_TRUE([selected->outputs isEqual:parsed->outputs]); + EXPECT_EQ(selected->aot_compiled, parsed->aot_compiled); + EXPECT_TRUE([selected->bundle_name isEqual:bundle]); + EXPECT_TRUE([selected->digest isEqual:dict[@"bundle_digests"][bundle]]); + EXPECT_FALSE([selected->files isKindOfClass:NSMutableDictionary.class]); EXPECT_TRUE(([selected->files isEqual:@{ [bundle stringByAppendingPathComponent:@"graph.bin"] : @14, [bundle stringByAppendingPathComponent:@"nested/weights.bin"] : @7 }])); - EXPECT_EQ(parsed->path, nil); + EXPECT_TRUE([parsed->files isEqual:files_snapshot]); + EXPECT_TRUE([parsed->bundle_digests isEqual:digests_snapshot]); + EXPECT_TRUE([parsed->architectures isEqual:architectures_snapshot]); } } +TEST(CoreAIManifestTest, SelectsSourceBundleUnchanged) { + auto dict = manifest_dict(); + auto parsed = parse_manifest(encode(dict)); + ASSERT_TRUE(parsed.ok()); + ASSERT_FALSE(parsed->aot_compiled); + // Selection ignores the architecture and platform for source bundles. + auto selected = select_assets(parsed.get(), @"anything", @"elsewhere"); + ASSERT_TRUE(selected.ok()); + EXPECT_TRUE([selected->path isEqualToString:dict[@"path"]]); + EXPECT_TRUE([selected->model_hash isEqual:parsed->model_hash]); + EXPECT_TRUE([selected->function isEqual:parsed->function]); + EXPECT_TRUE([selected->inputs isEqual:parsed->inputs]); + EXPECT_TRUE([selected->outputs isEqual:parsed->outputs]); + EXPECT_TRUE([selected->files isEqual:parsed->files]); + EXPECT_TRUE([selected->bundle_name isEqual:@"model.aimodel"]); + EXPECT_TRUE( + [selected->digest isEqual:parsed->bundle_digests[@"model.aimodel"]]); + EXPECT_EQ(selected->aot_compiled, parsed->aot_compiled); +} + +TEST(CoreAIManifestTest, ParsesRawBytesAndKeepsIndependentMetadata) { + auto original = aot_manifest_dict(); + NSMutableData* bytes = [encode(original) mutableCopy]; + auto parsed = parse_manifest(bytes.bytes, bytes.length); + ASSERT_TRUE(parsed.ok()); + [bytes setLength:0]; + [original removeAllObjects]; + auto selected = select_assets(parsed.get(), @"arch_b", @"macOS"); + ASSERT_TRUE(selected.ok()); + EXPECT_TRUE([selected->path isEqual:@"ab/model.arch_b.aimodelc"]); + EXPECT_EQ(selected->files.count, 2); + EXPECT_EQ(parsed->bundle_digests.count, 2); + EXPECT_FALSE([parsed->files isKindOfClass:NSMutableDictionary.class]); + EXPECT_FALSE([parsed->inputs isKindOfClass:NSMutableArray.class]); + EXPECT_FALSE([parsed->architectures isKindOfClass:NSMutableDictionary.class]); + EXPECT_EQ(parse_manifest(nullptr, 0).error(), Error::InvalidProgram); + EXPECT_EQ(parse_manifest("", 0).error(), Error::InvalidProgram); + EXPECT_EQ(parse_manifest("garbage", 7).error(), Error::InvalidProgram); +} + +TEST(CoreAIManifestTest, ManifestTypesAreNotDefaultConstructibleOrAssignable) { + static_assert(std::is_copy_constructible_v); + static_assert(std::is_move_constructible_v); + static_assert(std::is_copy_constructible_v); + static_assert(std::is_move_constructible_v); + static_assert(!std::is_convertible_v); + static_assert(!std::is_default_constructible_v); + static_assert(!std::is_copy_assignable_v); + static_assert(!std::is_move_assignable_v); + static_assert(!std::is_default_constructible_v); + static_assert(!std::is_copy_assignable_v); + static_assert(!std::is_move_assignable_v); + SUCCEED(); +} + +TEST(CoreAIManifestTest, ValidationHelpersMatchParserRules) { + EXPECT_FALSE(nonempty_string(nil)); + EXPECT_FALSE(nonempty_string(@"")); + EXPECT_TRUE(nonempty_string(@"ab")); + const unichar characters[] = {'a', 0, 'b'}; + NSString* embedded_nul = [NSString stringWithCharacters:characters length:3]; + ASSERT_EQ(embedded_nul.length, 3); + EXPECT_FALSE(nonempty_string(embedded_nul)); + EXPECT_FALSE(relative_path(embedded_nul)); + EXPECT_FALSE(lowercase_hex(embedded_nul)); + EXPECT_FALSE(nonempty_string(@1)); + EXPECT_FALSE(lowercase_hex((id) @1, 64)); + EXPECT_FALSE(lowercase_hex((id)NSNull.null, 64)); + EXPECT_FALSE(relative_path(@"/ab/model.aimodel")); + EXPECT_FALSE(relative_path(@"ab/../model.aimodel")); + EXPECT_FALSE(relative_path(@"a\\b")); + EXPECT_FALSE(relative_path(@"a//b")); + EXPECT_TRUE(relative_path(@"ab/model.aimodel")); + EXPECT_TRUE(lowercase_hex(@"ab")); + EXPECT_FALSE(lowercase_hex(@"")); + EXPECT_FALSE(lowercase_hex(@"AB")); + EXPECT_FALSE(lowercase_hex(@"ag")); + EXPECT_TRUE(lowercase_hex(fixture_identity('a'), 64)); + EXPECT_FALSE(lowercase_hex([fixture_identity('a') substringFromIndex:1], 64)); + EXPECT_FALSE(lowercase_hex(fixture_identity('a').uppercaseString, 64)); +} + TEST(CoreAIManifestTest, RejectsMismatchedAotPlatformAndArchitecture) { auto ios = aot_manifest_dict(); ios[@"platform"] = @"iOS";