arcane-os 0.1.0-dev.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +154 -0
- package/COMMERCIAL-LICENSE.md +13 -0
- package/LICENSE +661 -0
- package/NOTICE +70 -0
- package/README.md +448 -0
- package/bin/arcane-test.mjs +741 -0
- package/bin/arcane.mjs +5 -0
- package/docs/architecture.md +234 -0
- package/docs/compatibility.md +36 -0
- package/docs/event-manager.md +166 -0
- package/docs/platform-targets.md +108 -0
- package/docs/publishing.md +203 -0
- package/docs/reference/README.md +117 -0
- package/docs/reference/arcane-ollama.md +288 -0
- package/docs/reference/availability-and-normalization.md +123 -0
- package/docs/reference/behavioral-testing.md +86 -0
- package/docs/reference/cli.md +569 -0
- package/docs/reference/core/README.md +62 -0
- package/docs/reference/core/arcane-ai-contracts.md +873 -0
- package/docs/reference/core/arcane-api.md +601 -0
- package/docs/reference/core/arcane-entities.md +65 -0
- package/docs/reference/core/arcane-events.md +134 -0
- package/docs/reference/core/ollama-module.md +181 -0
- package/docs/reference/core/reference/arcane-api/ai-and-ollama.md +1909 -0
- package/docs/reference/core/reference/arcane-api/applications-terminal-capabilities.md +1057 -0
- package/docs/reference/core/reference/arcane-api/core-and-events.md +320 -0
- package/docs/reference/core/reference/arcane-api/filesystem-storage-preferences-appearance.md +610 -0
- package/docs/reference/core/reference/arcane-api/namespaces.md +1157 -0
- package/docs/reference/core/reference/arcane-api/platform-installation-users-system.md +1423 -0
- package/docs/reference/core/reference/arcane-api/session-provisioning-diagnostics-development.md +315 -0
- package/docs/reference/event-manager.md +957 -0
- package/docs/reference/inventory/package-api.json +2632 -0
- package/docs/reference/inventory/runtime-components.json +934 -0
- package/docs/reference/inventory/runtime-entities.json +26 -0
- package/docs/reference/inventory/runtime-modules.json +1249 -0
- package/docs/reference/protocols.md +242 -0
- package/docs/reference/runtime-components.md +1098 -0
- package/docs/reference/runtime-entities.md +303 -0
- package/docs/reference/runtime-modules.md +2010 -0
- package/docs/reference/sdk-api.md +4901 -0
- package/docs/roadmap.md +79 -0
- package/docs/work-amplification.md +124 -0
- package/node_modules/event-pubsub/CHANGELOG.md +55 -0
- package/node_modules/event-pubsub/MIGRATION.md +70 -0
- package/node_modules/event-pubsub/README.md +363 -0
- package/node_modules/event-pubsub/SECURITY.md +37 -0
- package/node_modules/event-pubsub/index.js +141 -0
- package/node_modules/event-pubsub/licence +21 -0
- package/node_modules/event-pubsub/package.json +59 -0
- package/node_modules/strong-type/README.md +408 -0
- package/node_modules/strong-type/assets/strong-type-header.png +0 -0
- package/node_modules/strong-type/index.js +1151 -0
- package/node_modules/strong-type/licence +21 -0
- package/node_modules/strong-type/node.js +125 -0
- package/node_modules/strong-type/package.json +61 -0
- package/package.json +95 -0
- package/runtime/ARCANE_RUNTIME_RELEASE.json +791 -0
- package/runtime/arcane/components/app-bar.html +468 -0
- package/runtime/arcane/components/assistant-panel.html +715 -0
- package/runtime/arcane/components/calculator.html +8 -0
- package/runtime/arcane/components/chart.html +655 -0
- package/runtime/arcane/components/chat.html +1225 -0
- package/runtime/arcane/components/conversation-view.html +13 -0
- package/runtime/arcane/components/dashboard-config.html +341 -0
- package/runtime/arcane/components/data-maintenance.html +112 -0
- package/runtime/arcane/components/data-view.html +92 -0
- package/runtime/arcane/components/directory-picker.html +197 -0
- package/runtime/arcane/components/document-inspector.html +252 -0
- package/runtime/arcane/components/file-drop.html +264 -0
- package/runtime/arcane/components/file-inspector.html +293 -0
- package/runtime/arcane/components/file-manager.html +1715 -0
- package/runtime/arcane/components/header.html +142 -0
- package/runtime/arcane/components/integration-settings.html +14 -0
- package/runtime/arcane/components/local-ai-status.html +360 -0
- package/runtime/arcane/components/markdown-document.html +1048 -0
- package/runtime/arcane/components/markdown-editor.html +360 -0
- package/runtime/arcane/components/media-embed.html +8 -0
- package/runtime/arcane/components/modal.html +402 -0
- package/runtime/arcane/components/output-panel.html +259 -0
- package/runtime/arcane/components/preferences-form.html +135 -0
- package/runtime/arcane/components/record-timeline.html +105 -0
- package/runtime/arcane/components/relationship-board.html +116 -0
- package/runtime/arcane/components/screen-capture.html +8 -0
- package/runtime/arcane/components/source-code-viewer.html +441 -0
- package/runtime/arcane/components/source-explanation.html +124 -0
- package/runtime/arcane/components/speech.html +365 -0
- package/runtime/arcane/components/summary-strip.html +177 -0
- package/runtime/arcane/components/table.html +77 -0
- package/runtime/arcane/components/task-progress.html +282 -0
- package/runtime/arcane/components/terminal-workspace.html +65 -0
- package/runtime/arcane/components/theme-editor.html +41 -0
- package/runtime/arcane/components/theme-switcher.html +46 -0
- package/runtime/arcane/components/unified-inbox.html +20 -0
- package/runtime/arcane/components/voice-transcription.html +476 -0
- package/runtime/arcane/components/weather-widget.html +8 -0
- package/runtime/arcane/components/web-navigator.html +239 -0
- package/runtime/arcane/css/communications.css +1 -0
- package/runtime/arcane/css/dashboard-config.css +45 -0
- package/runtime/arcane/css/document-site.css +981 -0
- package/runtime/arcane/css/layout.css +438 -0
- package/runtime/arcane/css/primitives.css +321 -0
- package/runtime/arcane/css/theme.css +112 -0
- package/runtime/arcane/css/utility-workspace.css +1 -0
- package/runtime/arcane/entities/ApiModelRecord.js +20 -0
- package/runtime/arcane/entities/Calculation.js +13 -0
- package/runtime/arcane/entities/Chat.js +581 -0
- package/runtime/arcane/entities/CommunicationMessage.js +29 -0
- package/runtime/arcane/entities/CommunicationThread.js +21 -0
- package/runtime/arcane/entities/Document.js +10 -0
- package/runtime/arcane/entities/File.js +143 -0
- package/runtime/arcane/entities/Image.js +135 -0
- package/runtime/arcane/entities/IntentEnvelope.js +834 -0
- package/runtime/arcane/entities/Preference.js +83 -0
- package/runtime/arcane/entities/TWiNPolicyDecision.js +1092 -0
- package/runtime/arcane/entities/TerminalSession.js +46 -0
- package/runtime/arcane/entities/Theme.js +107 -0
- package/runtime/arcane/entities/User.js +1046 -0
- package/runtime/arcane/entities/Weather.js +23 -0
- package/runtime/arcane/img/arcane-os-everywhere.png +0 -0
- package/runtime/arcane/img/arrow-left.png +0 -0
- package/runtime/arcane/img/arrow-right.png +0 -0
- package/runtime/arcane/img/doc.svg +5 -0
- package/runtime/arcane/img/folder.svg +4 -0
- package/runtime/arcane/img/image.svg +5 -0
- package/runtime/arcane/img/refresh.png +0 -0
- package/runtime/arcane/img/send.svg +9 -0
- package/runtime/arcane/img/trash.svg +5 -0
- package/runtime/arcane/img/upload.svg +5 -0
- package/runtime/arcane/modules/AI.js +2048 -0
- package/runtime/arcane/modules/AIPreferenceRuntime.js +32 -0
- package/runtime/arcane/modules/AIPreferenceTuple.js +92 -0
- package/runtime/arcane/modules/AIResponseLength.js +42 -0
- package/runtime/arcane/modules/AIResponseURLPolicy.js +626 -0
- package/runtime/arcane/modules/AnsiText.js +53 -0
- package/runtime/arcane/modules/ApiModelDatabase.js +25 -0
- package/runtime/arcane/modules/AppDataScope.js +245 -0
- package/runtime/arcane/modules/AppearancePreferences.js +28 -0
- package/runtime/arcane/modules/ArcaneCommunicationBridge.js +22 -0
- package/runtime/arcane/modules/ArcaneNavigationPolicy.js +135 -0
- package/runtime/arcane/modules/ArcaneNetworkPolicy.js +255 -0
- package/runtime/arcane/modules/AsyncBoundary.js +161 -0
- package/runtime/arcane/modules/BrowserTestSuite.js +326 -0
- package/runtime/arcane/modules/CalculatorEngine.js +20 -0
- package/runtime/arcane/modules/CaseEvidenceIndexer.js +134 -0
- package/runtime/arcane/modules/ChartLibrary.js +35 -0
- package/runtime/arcane/modules/ChatRecords.js +13 -0
- package/runtime/arcane/modules/CommunicationAppController.js +43 -0
- package/runtime/arcane/modules/CommunicationHub.js +16 -0
- package/runtime/arcane/modules/CommunicationPreferences.js +13 -0
- package/runtime/arcane/modules/CommunicationProviderRegistry.js +16 -0
- package/runtime/arcane/modules/ComponentContracts.js +586 -0
- package/runtime/arcane/modules/ConfiguredAIChatSession.js +288 -0
- package/runtime/arcane/modules/ConversationActionItems.js +488 -0
- package/runtime/arcane/modules/ConversationClosingReport.js +274 -0
- package/runtime/arcane/modules/ConversationTimebox.js +527 -0
- package/runtime/arcane/modules/CoreLocalModelCatalog.js +255 -0
- package/runtime/arcane/modules/DBLS.js +171 -0
- package/runtime/arcane/modules/DBOPFS.js +1154 -0
- package/runtime/arcane/modules/DBOPFSWorker.js +116 -0
- package/runtime/arcane/modules/DataMaintenance.js +91 -0
- package/runtime/arcane/modules/DevelopmentWorkspace.js +74 -0
- package/runtime/arcane/modules/DirectoryPicker.js +109 -0
- package/runtime/arcane/modules/DocumentNavigation.js +223 -0
- package/runtime/arcane/modules/Errors.js +1025 -0
- package/runtime/arcane/modules/GifEncoder.js +29 -0
- package/runtime/arcane/modules/HTMLImport.js +114 -0
- package/runtime/arcane/modules/InMemoryCommunicationProvider.js +14 -0
- package/runtime/arcane/modules/IsolatedModelQuestionRunner.js +275 -0
- package/runtime/arcane/modules/LocalAIReadiness.js +870 -0
- package/runtime/arcane/modules/LocalAIReadinessController.js +156 -0
- package/runtime/arcane/modules/MD.js +111 -0
- package/runtime/arcane/modules/Mail.js +352 -0
- package/runtime/arcane/modules/MailTransport.mjs +180 -0
- package/runtime/arcane/modules/Marked.min.js +71 -0
- package/runtime/arcane/modules/MemoryRecords.js +44 -0
- package/runtime/arcane/modules/MessageAdvisory.js +38 -0
- package/runtime/arcane/modules/ModelDefinition.js +189 -0
- package/runtime/arcane/modules/Ollama.js +74 -0
- package/runtime/arcane/modules/OllamaModelIdentifier.js +21 -0
- package/runtime/arcane/modules/OllamaSettings.js +24 -0
- package/runtime/arcane/modules/OpenMeteoWeatherProvider.js +14 -0
- package/runtime/arcane/modules/PreferenceStore.js +109 -0
- package/runtime/arcane/modules/QRCode.min.js +1 -0
- package/runtime/arcane/modules/Questionnaire.js +61 -0
- package/runtime/arcane/modules/RecordLinkIndex.js +24 -0
- package/runtime/arcane/modules/RecordPassageIndex.js +222 -0
- package/runtime/arcane/modules/RecordReviewStore.js +94 -0
- package/runtime/arcane/modules/RevocableProjectionLedger.js +1623 -0
- package/runtime/arcane/modules/RiskSignalAnalyzer.js +37 -0
- package/runtime/arcane/modules/ScamRiskPolicy.js +62 -0
- package/runtime/arcane/modules/ScopedOPFSCache.js +183 -0
- package/runtime/arcane/modules/ScreenCapture.js +20 -0
- package/runtime/arcane/modules/SpeechPlayback.js +581 -0
- package/runtime/arcane/modules/StaticDocumentCatalog.js +1248 -0
- package/runtime/arcane/modules/SystemAppearance.js +20 -0
- package/runtime/arcane/modules/SystemPlatformPresentation.js +51 -0
- package/runtime/arcane/modules/SystemToolRegistry.js +28 -0
- package/runtime/arcane/modules/TerminalClient.js +52 -0
- package/runtime/arcane/modules/TerminalCommandRegistry.js +50 -0
- package/runtime/arcane/modules/ThemeBootstrap.js +26 -0
- package/runtime/arcane/modules/ThemeManager.js +131 -0
- package/runtime/arcane/modules/TimeGuard.js +149 -0
- package/runtime/arcane/modules/ToolCallRouter.js +83 -0
- package/runtime/arcane/modules/WaitForComponent.js +102 -0
- package/runtime/arcane/modules/YouTubeMedia.js +16 -0
- package/runtime/arcane/modules/uPlot.LICENSE.txt +21 -0
- package/runtime/arcane/modules/uPlot.iife.min.js +2 -0
- package/runtime/arcane/modules/uPlot.min.css +1 -0
- package/runtime/arcane/security/arcane-network-policy.json +6 -0
- package/runtime/strong-type/index.js +352 -0
- package/runtime/strong-type/licence +21 -0
- package/runtime/strong-type/package.json +45 -0
- package/schemas/arcane-app-bundle.schema.json +125 -0
- package/schemas/arcane-app.schema.json +329 -0
- package/schemas/arcane-lock.schema.json +86 -0
- package/schemas/arcane-package.schema.json +224 -0
- package/schemas/cli-event.schema.json +122 -0
- package/schemas/event-stack.schema.json +152 -0
- package/schemas/native-build-plan.schema.json +176 -0
- package/schemas/target-adapter.schema.json +117 -0
- package/src/app-descriptor.mjs +500 -0
- package/src/cli/main.mjs +561 -0
- package/src/constants.mjs +31 -0
- package/src/dev-server.mjs +718 -0
- package/src/doctor.mjs +315 -0
- package/src/dom-event-instrumentation.mjs +594 -0
- package/src/errors.mjs +75 -0
- package/src/event-manager.mjs +1342 -0
- package/src/event-queue.mjs +138 -0
- package/src/events.mjs +219 -0
- package/src/index.mjs +177 -0
- package/src/integrated-provider-loader.mjs +432 -0
- package/src/native-plan.mjs +698 -0
- package/src/native-provider-loader.mjs +1126 -0
- package/src/packager/core.mjs +2691 -0
- package/src/process.mjs +353 -0
- package/src/release-bundle.mjs +2523 -0
- package/src/repository.mjs +90 -0
- package/src/runtime.mjs +452 -0
- package/src/scaffold.mjs +380 -0
- package/src/targets/index.mjs +436 -0
- package/src/templates/assets/app-icon.png +0 -0
- package/src/templates/workspace-template.mjs +388 -0
- package/src/testing-loader.mjs +9 -0
- package/src/testing.mjs +427 -0
- package/src/toolchain.mjs +1335 -0
- package/src/update-check.mjs +307 -0
- package/src/workspace.mjs +449 -0
|
@@ -0,0 +1,2523 @@
|
|
|
1
|
+
import {createHash,randomBytes} from 'node:crypto';
|
|
2
|
+
import {once} from 'node:events';
|
|
3
|
+
import {constants as FS_CONSTANTS} from 'node:fs';
|
|
4
|
+
import {
|
|
5
|
+
link,
|
|
6
|
+
lstat,
|
|
7
|
+
mkdir,
|
|
8
|
+
open,
|
|
9
|
+
realpath,
|
|
10
|
+
rm
|
|
11
|
+
} from 'node:fs/promises';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import {Readable,Transform,Writable} from 'node:stream';
|
|
14
|
+
import {pipeline} from 'node:stream/promises';
|
|
15
|
+
import {createDeflateRaw,createGunzip} from 'node:zlib';
|
|
16
|
+
|
|
17
|
+
import {appDescriptorSha256,projectPackageManifest,validateAppDescriptor} from './app-descriptor.mjs';
|
|
18
|
+
import {SDK_NAME,SDK_VERSION} from './constants.mjs';
|
|
19
|
+
import {ArcaneError,ERROR_CODES,throwIfAborted} from './errors.mjs';
|
|
20
|
+
import {
|
|
21
|
+
authenticateAppReleaseAuthority,
|
|
22
|
+
RELEASE_MANIFEST_NAME,
|
|
23
|
+
PACKAGER_VERSION,
|
|
24
|
+
parseSemver
|
|
25
|
+
} from './packager/core.mjs';
|
|
26
|
+
|
|
27
|
+
export const APP_BUNDLE_MANIFEST_NAME='ARCANE_APP_BUNDLE.json';
|
|
28
|
+
export const APP_BUNDLE_DESCRIPTOR_NAME='arcane-app.json';
|
|
29
|
+
export const APP_BUNDLE_RELEASE_PATH=`payload/${RELEASE_MANIFEST_NAME}`;
|
|
30
|
+
export const APP_BUNDLE_KIND='arcane-app-release-bundle';
|
|
31
|
+
export const APP_BUNDLE_FORMAT='ustar+gzip';
|
|
32
|
+
export const APP_BUNDLE_SCHEMA_VERSION=1;
|
|
33
|
+
export const APP_BUNDLE_EXTENSION='.arcane-app.tar.gz';
|
|
34
|
+
export const APP_BUNDLE_SUPPORTED_SDK_VERSIONS=Object.freeze([SDK_VERSION]);
|
|
35
|
+
|
|
36
|
+
export const APP_BUNDLE_LIMITS=Object.freeze({
|
|
37
|
+
maxCompressedBytes:512*1024*1024,
|
|
38
|
+
maxExpandedBytes:1024*1024*1024,
|
|
39
|
+
maxEntries:16384,
|
|
40
|
+
maxPayloadFiles:16381,
|
|
41
|
+
maxEntryBytes:512*1024*1024,
|
|
42
|
+
maxControlBytes:4*1024*1024,
|
|
43
|
+
maxPathBytes:256,
|
|
44
|
+
maxExpansionRatio:200,
|
|
45
|
+
expansionSlackBytes:16*1024*1024
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const TAR_BLOCK_BYTES=512;
|
|
49
|
+
const TAR_END_BYTES=TAR_BLOCK_BYTES*2;
|
|
50
|
+
const ARCHIVE_MODE=0o644;
|
|
51
|
+
const GZIP_HEADER=Buffer.from([0x1f,0x8b,0x08,0x00,0x00,0x00,0x00,0x00,0x02,0x03]);
|
|
52
|
+
const READ_ONLY_NO_FOLLOW=FS_CONSTANTS.O_RDONLY|(FS_CONSTANTS.O_NOFOLLOW??0);
|
|
53
|
+
const SHA256_PATTERN=/^[0-9a-f]{64}$/u;
|
|
54
|
+
const APP_ID_PATTERN=/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
|
55
|
+
const WINDOWS_RESERVED_PATTERN=
|
|
56
|
+
/^(?:con|prn|aux|nul|clock\$|conin\$|conout\$|com[1-9¹²³]|lpt[1-9¹²³])(?:[.]|$)/iu;
|
|
57
|
+
const WINDOWS_UNSAFE_FILENAME_CHARACTER_PATTERN=/[<>"|?*]/u;
|
|
58
|
+
const PORTABLE_RESERVED_FILENAME_PATTERN=WINDOWS_RESERVED_PATTERN;
|
|
59
|
+
const CONTROL_PATHS=new Set([
|
|
60
|
+
APP_BUNDLE_MANIFEST_NAME,
|
|
61
|
+
APP_BUNDLE_DESCRIPTOR_NAME,
|
|
62
|
+
APP_BUNDLE_RELEASE_PATH
|
|
63
|
+
]);
|
|
64
|
+
const textDecoder=new TextDecoder('utf-8',{fatal:true});
|
|
65
|
+
|
|
66
|
+
function fail(message,code=ERROR_CODES.integrityFailed,details){
|
|
67
|
+
throw new ArcaneError(code,message,{details});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function appendErrorWarning(error,warning){
|
|
71
|
+
try{
|
|
72
|
+
error.message=`${String(error?.message??error)} ${warning}`;
|
|
73
|
+
}catch{}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function emit(onEvent,event){
|
|
77
|
+
await onEvent?.(Object.freeze(event));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function sha256(value){
|
|
81
|
+
return createHash('sha256').update(value).digest('hex');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function canonicalJsonBytes(value){
|
|
85
|
+
return Buffer.from(`${JSON.stringify(value,null,2)}\n`,'utf8');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function compareText(left,right){
|
|
89
|
+
return Buffer.compare(Buffer.from(String(left),'utf8'),Buffer.from(String(right),'utf8'));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isPlainObject(value){
|
|
93
|
+
if(value===null||typeof value!=='object'||Array.isArray(value))return false;
|
|
94
|
+
const prototype=Object.getPrototypeOf(value);
|
|
95
|
+
return prototype===Object.prototype||prototype===null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function assertExactKeys(value,keys,label){
|
|
99
|
+
if(!isPlainObject(value))fail(`${label} must be a JSON object.`);
|
|
100
|
+
const actual=Object.keys(value);
|
|
101
|
+
if(JSON.stringify(actual)!==JSON.stringify(keys)){
|
|
102
|
+
fail(`${label} must contain exactly these keys in canonical order: ${keys.join(', ')}.`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function assertInteger(value,{minimum=0,maximum=Number.MAX_SAFE_INTEGER,label}){
|
|
107
|
+
if(!Number.isSafeInteger(value)||value<minimum||value>maximum){
|
|
108
|
+
fail(`${label} must be an integer from ${minimum} through ${maximum}.`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function assertSha256(value,label){
|
|
113
|
+
if(typeof value!=='string'||!SHA256_PATTERN.test(value)){
|
|
114
|
+
fail(`${label} must be a lowercase SHA-256 digest.`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function pathKey(value){
|
|
119
|
+
return value.normalize('NFC').toLowerCase();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function registerPortablePathTopology(topology,filePath,label){
|
|
123
|
+
const segments=filePath.split('/');
|
|
124
|
+
let prefix='';
|
|
125
|
+
for(let index=0;index<segments.length;index+=1){
|
|
126
|
+
prefix=prefix?`${prefix}/${segments[index]}`:segments[index];
|
|
127
|
+
const key=pathKey(prefix);
|
|
128
|
+
const kind=index===segments.length-1?'file':'directory';
|
|
129
|
+
const existingKind=topology.kinds.get(key);
|
|
130
|
+
if(existingKind&&existingKind!==kind){
|
|
131
|
+
fail(`${label} uses ${prefix} as both a file and a directory.`);
|
|
132
|
+
}
|
|
133
|
+
const spelling=topology.spellings.get(key);
|
|
134
|
+
if(spelling!==undefined&&spelling!==prefix){
|
|
135
|
+
fail(`${label} has a case-colliding path prefix: ${spelling} and ${prefix}.`);
|
|
136
|
+
}
|
|
137
|
+
if(existingKind==='file'&&kind==='file'){
|
|
138
|
+
fail(`${label} has a duplicate path: ${prefix}.`);
|
|
139
|
+
}
|
|
140
|
+
topology.kinds.set(key,kind);
|
|
141
|
+
topology.spellings.set(key,prefix);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function validatePortablePathTopology(filePaths,label){
|
|
146
|
+
const topology={kinds:new Map(),spellings:new Map()};
|
|
147
|
+
for(const filePath of filePaths)registerPortablePathTopology(topology,filePath,label);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function validateAppBundlePath(value,label='bundle path'){
|
|
151
|
+
if(typeof value!=='string'||!value||value!==value.normalize('NFC')){
|
|
152
|
+
fail(`${label} must be a nonempty NFC-normalized string.`);
|
|
153
|
+
}
|
|
154
|
+
const bytes=Buffer.byteLength(value,'utf8');
|
|
155
|
+
if(bytes>APP_BUNDLE_LIMITS.maxPathBytes){
|
|
156
|
+
fail(`${label} exceeds the ${APP_BUNDLE_LIMITS.maxPathBytes}-byte USTAR limit.`);
|
|
157
|
+
}
|
|
158
|
+
if(value.startsWith('/')||value.startsWith('\\')||value.includes('\\')
|
|
159
|
+
||value.includes(':')||/[\u0000-\u001f\u007f]/u.test(value)){
|
|
160
|
+
fail(`${label} is not a canonical relative archive path.`);
|
|
161
|
+
}
|
|
162
|
+
const segments=value.split('/');
|
|
163
|
+
if(segments.some(segment=>!segment||segment==='.'||segment==='..'
|
|
164
|
+
||WINDOWS_UNSAFE_FILENAME_CHARACTER_PATTERN.test(segment)
|
|
165
|
+
||/[. ]$/u.test(segment)||WINDOWS_RESERVED_PATTERN.test(segment))){
|
|
166
|
+
fail(`${label} contains an unsafe path segment.`);
|
|
167
|
+
}
|
|
168
|
+
return value;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function splitUstarPath(value){
|
|
172
|
+
validateAppBundlePath(value,'USTAR entry path');
|
|
173
|
+
const complete=Buffer.from(value,'utf8');
|
|
174
|
+
if(complete.length<=100){
|
|
175
|
+
return {name:complete,prefix:Buffer.alloc(0)};
|
|
176
|
+
}
|
|
177
|
+
for(let index=value.length-1;index>0;index-=1){
|
|
178
|
+
if(value[index]!=='/')continue;
|
|
179
|
+
const prefix=Buffer.from(value.slice(0,index),'utf8');
|
|
180
|
+
const name=Buffer.from(value.slice(index+1),'utf8');
|
|
181
|
+
if(prefix.length<=155&&name.length>0&&name.length<=100){
|
|
182
|
+
return {name,prefix};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
fail(`USTAR entry path cannot be represented without an extension header: ${value}.`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function writeOctal(header,offset,length,value){
|
|
189
|
+
if(!Number.isSafeInteger(value)||value<0)fail('USTAR numeric fields must be nonnegative integers.');
|
|
190
|
+
const octal=value.toString(8);
|
|
191
|
+
if(octal.length>length-1)fail('USTAR numeric field overflowed its canonical octal field.');
|
|
192
|
+
header.write(`${octal.padStart(length-1,'0')}\0`,offset,length,'ascii');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function createCanonicalUstarHeader(entryPath,size){
|
|
196
|
+
assertInteger(size,{minimum:0,maximum:APP_BUNDLE_LIMITS.maxEntryBytes,label:'USTAR entry size'});
|
|
197
|
+
const {name,prefix}=splitUstarPath(entryPath);
|
|
198
|
+
const header=Buffer.alloc(TAR_BLOCK_BYTES,0);
|
|
199
|
+
name.copy(header,0);
|
|
200
|
+
writeOctal(header,100,8,ARCHIVE_MODE);
|
|
201
|
+
writeOctal(header,108,8,0);
|
|
202
|
+
writeOctal(header,116,8,0);
|
|
203
|
+
writeOctal(header,124,12,size);
|
|
204
|
+
writeOctal(header,136,12,0);
|
|
205
|
+
header.fill(0x20,148,156);
|
|
206
|
+
header[156]=0x30;
|
|
207
|
+
header.write('ustar\0',257,6,'ascii');
|
|
208
|
+
header.write('00',263,2,'ascii');
|
|
209
|
+
prefix.copy(header,345);
|
|
210
|
+
const checksum=header.reduce((total,byte)=>total+byte,0);
|
|
211
|
+
const checksumText=checksum.toString(8).padStart(6,'0');
|
|
212
|
+
if(checksumText.length!==6)fail('USTAR header checksum overflowed its canonical field.');
|
|
213
|
+
header.write(`${checksumText}\0 `,148,8,'ascii');
|
|
214
|
+
return header;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function decodeUstarField(field,label){
|
|
218
|
+
const zero=field.indexOf(0);
|
|
219
|
+
const end=zero===-1?field.length:zero;
|
|
220
|
+
if(zero!==-1&&field.subarray(zero).some(byte=>byte!==0)){
|
|
221
|
+
fail(`${label} contains nonzero bytes after its terminator.`);
|
|
222
|
+
}
|
|
223
|
+
try{
|
|
224
|
+
return textDecoder.decode(field.subarray(0,end));
|
|
225
|
+
}catch(error){
|
|
226
|
+
fail(`${label} is not canonical UTF-8.`,ERROR_CODES.integrityFailed,{cause:error.message});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function parseCanonicalOctal(field,label){
|
|
231
|
+
const text=field.toString('ascii');
|
|
232
|
+
if(!/^[0-7]+\0$/u.test(text))fail(`${label} is not canonical NUL-terminated octal.`);
|
|
233
|
+
const value=Number.parseInt(text.slice(0,-1),8);
|
|
234
|
+
if(!Number.isSafeInteger(value))fail(`${label} exceeds the safe integer range.`);
|
|
235
|
+
return value;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function parseCanonicalUstarHeader(header){
|
|
239
|
+
if(header.length!==TAR_BLOCK_BYTES)fail('USTAR header is incomplete.');
|
|
240
|
+
const name=decodeUstarField(header.subarray(0,100),'USTAR name');
|
|
241
|
+
const prefix=decodeUstarField(header.subarray(345,500),'USTAR prefix');
|
|
242
|
+
const entryPath=prefix?`${prefix}/${name}`:name;
|
|
243
|
+
const size=parseCanonicalOctal(header.subarray(124,136),'USTAR size');
|
|
244
|
+
const canonical=createCanonicalUstarHeader(entryPath,size);
|
|
245
|
+
if(!header.equals(canonical)){
|
|
246
|
+
fail(`USTAR header is not the canonical regular-file header for ${entryPath}.`);
|
|
247
|
+
}
|
|
248
|
+
return {path:entryPath,size};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function crcTable(){
|
|
252
|
+
const table=new Uint32Array(256);
|
|
253
|
+
for(let index=0;index<256;index+=1){
|
|
254
|
+
let value=index;
|
|
255
|
+
for(let bit=0;bit<8;bit+=1){
|
|
256
|
+
value=(value&1)?(0xedb88320^(value>>>1)):(value>>>1);
|
|
257
|
+
}
|
|
258
|
+
table[index]=value>>>0;
|
|
259
|
+
}
|
|
260
|
+
return table;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const CRC32_TABLE=crcTable();
|
|
264
|
+
|
|
265
|
+
function updateCrc32(current,bytes){
|
|
266
|
+
let value=current;
|
|
267
|
+
for(const byte of bytes){
|
|
268
|
+
value=CRC32_TABLE[(value^byte)&0xff]^(value>>>8);
|
|
269
|
+
}
|
|
270
|
+
return value>>>0;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function fileIdentity(info){
|
|
274
|
+
return Object.freeze({
|
|
275
|
+
device:String(info.dev),
|
|
276
|
+
inode:String(info.ino),
|
|
277
|
+
bytes:Number(info.size),
|
|
278
|
+
modifiedNanoseconds:String(info.mtimeNs),
|
|
279
|
+
changedNanoseconds:String(info.ctimeNs),
|
|
280
|
+
links:String(info.nlink)
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function identityMatches(info,identity){
|
|
285
|
+
return String(info.dev)===identity.device
|
|
286
|
+
&&String(info.ino)===identity.inode
|
|
287
|
+
&&Number(info.size)===identity.bytes
|
|
288
|
+
&&String(info.mtimeNs)===identity.modifiedNanoseconds
|
|
289
|
+
&&String(info.ctimeNs)===identity.changedNanoseconds
|
|
290
|
+
&&String(info.nlink)===identity.links;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function identitiesEqual(left,right){
|
|
294
|
+
return left.device===right.device
|
|
295
|
+
&&left.inode===right.inode
|
|
296
|
+
&&left.bytes===right.bytes
|
|
297
|
+
&&left.modifiedNanoseconds===right.modifiedNanoseconds
|
|
298
|
+
&&left.changedNanoseconds===right.changedNanoseconds
|
|
299
|
+
&&left.links===right.links;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function fileObjectMatches(info,identity){
|
|
303
|
+
return String(info.dev)===identity.device&&String(info.ino)===identity.inode;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function anchoredContentObjectMatches(info,identity){
|
|
307
|
+
return info.isFile()
|
|
308
|
+
&&fileObjectMatches(info,identity)
|
|
309
|
+
&&Number(info.size)===identity.bytes
|
|
310
|
+
&&String(info.nlink)===identity.links;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function anchoredFileSha256(handle,identity,label,{signal}={}){
|
|
314
|
+
throwIfAborted(signal);
|
|
315
|
+
const before=await handle.stat({bigint:true});
|
|
316
|
+
if(!anchoredContentObjectMatches(before,identity)){
|
|
317
|
+
fail(`${label} did not retain its anchored file object before content verification.`);
|
|
318
|
+
}
|
|
319
|
+
const digest=createHash('sha256');
|
|
320
|
+
const buffer=Buffer.allocUnsafe(64*1024);
|
|
321
|
+
let position=0;
|
|
322
|
+
while(position<identity.bytes){
|
|
323
|
+
throwIfAborted(signal);
|
|
324
|
+
const requested=Math.min(buffer.length,identity.bytes-position);
|
|
325
|
+
const result=await handle.read(buffer,0,requested,position);
|
|
326
|
+
if(result.bytesRead===0)fail(`${label} ended before its anchored byte length.`);
|
|
327
|
+
digest.update(buffer.subarray(0,result.bytesRead));
|
|
328
|
+
position+=result.bytesRead;
|
|
329
|
+
}
|
|
330
|
+
throwIfAborted(signal);
|
|
331
|
+
const probe=Buffer.alloc(1);
|
|
332
|
+
const extra=await handle.read(probe,0,1,identity.bytes);
|
|
333
|
+
if(extra.bytesRead!==0)fail(`${label} grew beyond its anchored byte length.`);
|
|
334
|
+
throwIfAborted(signal);
|
|
335
|
+
const after=await handle.stat({bigint:true});
|
|
336
|
+
if(!anchoredContentObjectMatches(after,identity)){
|
|
337
|
+
fail(`${label} changed while its anchored content was verified.`);
|
|
338
|
+
}
|
|
339
|
+
throwIfAborted(signal);
|
|
340
|
+
return Object.freeze({
|
|
341
|
+
sha256:digest.digest('hex'),
|
|
342
|
+
before,
|
|
343
|
+
after
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function assertAnchoredContent(handle,identity,expectedSha256,label,{signal}={}){
|
|
348
|
+
const inspected=await anchoredFileSha256(handle,identity,label,{signal});
|
|
349
|
+
if(inspected.sha256!==expectedSha256){
|
|
350
|
+
fail(`${label} did not retain its anchored content identity.`);
|
|
351
|
+
}
|
|
352
|
+
if(!identityMatches(inspected.before,identity)||!identityMatches(inspected.after,identity)){
|
|
353
|
+
fail(`${label} did not retain its anchored metadata identity.`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function isInside(parent,candidate){
|
|
358
|
+
const relative=path.relative(parent,candidate);
|
|
359
|
+
return relative===''||(!relative.startsWith('..')&&!path.isAbsolute(relative));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function openStableFile(filePath,label,{maximum=APP_BUNDLE_LIMITS.maxEntryBytes}={}){
|
|
363
|
+
const before=await lstat(filePath,{bigint:true});
|
|
364
|
+
if(before.isSymbolicLink()||!before.isFile()){
|
|
365
|
+
fail(`${label} must be a regular file, not a link or special entry.`);
|
|
366
|
+
}
|
|
367
|
+
if(before.nlink!==1n){
|
|
368
|
+
fail(`${label} must have exactly one filesystem link.`,ERROR_CODES.policyDenied);
|
|
369
|
+
}
|
|
370
|
+
if(Number(before.size)>maximum)fail(`${label} exceeds its ${maximum}-byte limit.`,ERROR_CODES.policyDenied);
|
|
371
|
+
let handle;
|
|
372
|
+
try{
|
|
373
|
+
handle=await open(filePath,READ_ONLY_NO_FOLLOW);
|
|
374
|
+
}catch(error){
|
|
375
|
+
if(error?.code==='ELOOP')fail(`${label} became a symbolic link.`);
|
|
376
|
+
throw error;
|
|
377
|
+
}
|
|
378
|
+
try{
|
|
379
|
+
const opened=await handle.stat({bigint:true});
|
|
380
|
+
if(!opened.isFile()||opened.nlink!==1n||!identityMatches(opened,fileIdentity(before))){
|
|
381
|
+
fail(`${label} changed while it was opened.`);
|
|
382
|
+
}
|
|
383
|
+
return {handle,identity:fileIdentity(opened)};
|
|
384
|
+
}catch(error){
|
|
385
|
+
await handle.close().catch(()=>{});
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function readExactOpenedBytes(opened,label,{signal}={}){
|
|
391
|
+
const bytes=Buffer.alloc(opened.identity.bytes);
|
|
392
|
+
let position=0;
|
|
393
|
+
while(position<bytes.length){
|
|
394
|
+
throwIfAborted(signal);
|
|
395
|
+
const result=await opened.handle.read(bytes,position,bytes.length-position,position);
|
|
396
|
+
if(result.bytesRead===0)fail(`${label} ended before its recorded byte length.`);
|
|
397
|
+
position+=result.bytesRead;
|
|
398
|
+
}
|
|
399
|
+
const probe=Buffer.alloc(1);
|
|
400
|
+
const extra=await opened.handle.read(probe,0,1,opened.identity.bytes);
|
|
401
|
+
if(extra.bytesRead!==0)fail(`${label} grew beyond its recorded byte length.`);
|
|
402
|
+
throwIfAborted(signal);
|
|
403
|
+
const after=await opened.handle.stat({bigint:true});
|
|
404
|
+
if(after.nlink!==1n||!identityMatches(after,opened.identity)){
|
|
405
|
+
fail(`${label} changed while it was read.`);
|
|
406
|
+
}
|
|
407
|
+
return bytes;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function readStableControlFile(filePath,label,{signal}={}){
|
|
411
|
+
throwIfAborted(signal);
|
|
412
|
+
const opened=await openStableFile(filePath,label,{maximum:APP_BUNDLE_LIMITS.maxControlBytes});
|
|
413
|
+
try{
|
|
414
|
+
const bytes=await readExactOpenedBytes(opened,label,{signal});
|
|
415
|
+
const current=await lstat(filePath,{bigint:true});
|
|
416
|
+
if(current.isSymbolicLink()||!current.isFile()||current.nlink!==1n
|
|
417
|
+
||!identityMatches(current,opened.identity)){
|
|
418
|
+
fail(`${label} path changed while it was read.`);
|
|
419
|
+
}
|
|
420
|
+
return bytes;
|
|
421
|
+
}finally{
|
|
422
|
+
await opened.handle.close();
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function* verifiedFileChunks(root,identity,{signal}={}){
|
|
427
|
+
throwIfAborted(signal);
|
|
428
|
+
const filePath=path.join(root,...identity.path.split('/'));
|
|
429
|
+
const opened=await openStableFile(filePath,`release payload ${identity.path}`);
|
|
430
|
+
try{
|
|
431
|
+
const canonicalFile=await realpath(filePath);
|
|
432
|
+
if(!isInside(root,canonicalFile))fail(`Release payload escaped its root: ${identity.path}.`);
|
|
433
|
+
if(opened.identity.bytes!==identity.bytes){
|
|
434
|
+
fail(`Release payload size changed: ${identity.path}.`);
|
|
435
|
+
}
|
|
436
|
+
const digest=createHash('sha256');
|
|
437
|
+
let position=0;
|
|
438
|
+
const buffer=Buffer.allocUnsafe(64*1024);
|
|
439
|
+
while(position<identity.bytes){
|
|
440
|
+
throwIfAborted(signal);
|
|
441
|
+
const requested=Math.min(buffer.length,identity.bytes-position);
|
|
442
|
+
const {bytesRead}=await opened.handle.read(buffer,0,requested,position);
|
|
443
|
+
if(bytesRead===0)fail(`Release payload ended early: ${identity.path}.`);
|
|
444
|
+
const chunk=Buffer.from(buffer.subarray(0,bytesRead));
|
|
445
|
+
digest.update(chunk);
|
|
446
|
+
position+=bytesRead;
|
|
447
|
+
yield chunk;
|
|
448
|
+
}
|
|
449
|
+
const probe=Buffer.alloc(1);
|
|
450
|
+
const extra=await opened.handle.read(probe,0,1,identity.bytes);
|
|
451
|
+
if(extra.bytesRead!==0)fail(`Release payload grew while it was bundled: ${identity.path}.`);
|
|
452
|
+
const after=await opened.handle.stat({bigint:true});
|
|
453
|
+
if(after.nlink!==1n||!identityMatches(after,opened.identity)
|
|
454
|
+
||digest.digest('hex')!==identity.sha256){
|
|
455
|
+
fail(`Release payload changed while it was bundled: ${identity.path}.`);
|
|
456
|
+
}
|
|
457
|
+
const current=await lstat(filePath,{bigint:true});
|
|
458
|
+
if(current.isSymbolicLink()||!current.isFile()||!identityMatches(current,opened.identity)){
|
|
459
|
+
fail(`Release payload path changed while it was bundled: ${identity.path}.`);
|
|
460
|
+
}
|
|
461
|
+
}finally{
|
|
462
|
+
await opened.handle.close();
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function validateReleaseInventory(receipt){
|
|
467
|
+
if(!Array.isArray(receipt.files)||receipt.files.length<1
|
|
468
|
+
||receipt.files.length>APP_BUNDLE_LIMITS.maxPayloadFiles){
|
|
469
|
+
fail(`Verified release inventory must contain 1 through ${APP_BUNDLE_LIMITS.maxPayloadFiles} files.`);
|
|
470
|
+
}
|
|
471
|
+
const keys=new Set();
|
|
472
|
+
let totalBytes=0;
|
|
473
|
+
let previous=null;
|
|
474
|
+
const files=receipt.files.map((file,index)=>{
|
|
475
|
+
assertExactKeys(file,['path','bytes','sha256'],`release file ${index}`);
|
|
476
|
+
const relativePath=validateAppBundlePath(file.path,`release file ${index} path`);
|
|
477
|
+
if(pathKey(relativePath)===pathKey(RELEASE_MANIFEST_NAME)){
|
|
478
|
+
fail(`Release inventory must not contain ${RELEASE_MANIFEST_NAME}.`);
|
|
479
|
+
}
|
|
480
|
+
const key=pathKey(relativePath);
|
|
481
|
+
if(keys.has(key))fail(`Release inventory has a duplicate or case-colliding path: ${relativePath}.`);
|
|
482
|
+
keys.add(key);
|
|
483
|
+
if(previous!==null&&compareText(previous,relativePath)>=0){
|
|
484
|
+
fail('Release inventory must use strict canonical path order.');
|
|
485
|
+
}
|
|
486
|
+
previous=relativePath;
|
|
487
|
+
assertInteger(file.bytes,{
|
|
488
|
+
minimum:0,
|
|
489
|
+
maximum:APP_BUNDLE_LIMITS.maxEntryBytes,
|
|
490
|
+
label:`release file ${relativePath} bytes`
|
|
491
|
+
});
|
|
492
|
+
assertSha256(file.sha256,`release file ${relativePath} sha256`);
|
|
493
|
+
totalBytes+=file.bytes;
|
|
494
|
+
if(!Number.isSafeInteger(totalBytes)||totalBytes>APP_BUNDLE_LIMITS.maxExpandedBytes){
|
|
495
|
+
fail('Release payload exceeds the expanded archive limit.',ERROR_CODES.policyDenied);
|
|
496
|
+
}
|
|
497
|
+
return Object.freeze({path:relativePath,bytes:file.bytes,sha256:file.sha256});
|
|
498
|
+
});
|
|
499
|
+
validatePortablePathTopology(files.map(file=>file.path),'Release inventory');
|
|
500
|
+
if(totalBytes<1){
|
|
501
|
+
fail('Verified release payload must contain at least one byte.',ERROR_CODES.policyDenied);
|
|
502
|
+
}
|
|
503
|
+
if(receipt.fileCount!==files.length||receipt.totalBytes!==totalBytes
|
|
504
|
+
||receipt.contentSha256!==sha256(JSON.stringify(files))){
|
|
505
|
+
fail('Verified release receipt inventory totals are inconsistent.');
|
|
506
|
+
}
|
|
507
|
+
return Object.freeze(files);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function expectedReleaseApp(descriptor){
|
|
511
|
+
return Object.freeze({
|
|
512
|
+
id:descriptor.id,
|
|
513
|
+
displayName:descriptor.displayName,
|
|
514
|
+
version:descriptor.version,
|
|
515
|
+
entry:descriptor.package.entry,
|
|
516
|
+
start:`./apps/${descriptor.id}/${descriptor.package.entry}`,
|
|
517
|
+
security:descriptor.security,
|
|
518
|
+
localAIModelPolicy:descriptor.package.localAIModelPolicy
|
|
519
|
+
??Object.freeze({verified_only:true,models:Object.freeze([])})
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function releaseDocumentFromReceipt(receipt,descriptor,files){
|
|
524
|
+
const expectedApp=expectedReleaseApp(descriptor);
|
|
525
|
+
if(receipt.builder!==PACKAGER_VERSION
|
|
526
|
+
||JSON.stringify(receipt.app)!==JSON.stringify(expectedApp)
|
|
527
|
+
||!SHA256_PATTERN.test(receipt.policySha256??'')){
|
|
528
|
+
fail('Verified release receipt is not bound to the authored schema-2 descriptor.');
|
|
529
|
+
}
|
|
530
|
+
return Object.freeze({
|
|
531
|
+
schemaVersion:1,
|
|
532
|
+
builder:receipt.builder,
|
|
533
|
+
app:expectedApp,
|
|
534
|
+
policySha256:receipt.policySha256,
|
|
535
|
+
fileCount:receipt.fileCount,
|
|
536
|
+
totalBytes:receipt.totalBytes,
|
|
537
|
+
contentSha256:receipt.contentSha256,
|
|
538
|
+
files
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function createBundleManifest({descriptor,descriptorBytes,descriptorSha256,release,releaseBytes}){
|
|
543
|
+
const packageSha256=sha256(JSON.stringify(projectPackageManifest(descriptor)));
|
|
544
|
+
return Object.freeze({
|
|
545
|
+
schemaVersion:APP_BUNDLE_SCHEMA_VERSION,
|
|
546
|
+
kind:APP_BUNDLE_KIND,
|
|
547
|
+
format:APP_BUNDLE_FORMAT,
|
|
548
|
+
sdk:Object.freeze({name:SDK_NAME,version:SDK_VERSION}),
|
|
549
|
+
app:Object.freeze({id:descriptor.id,version:descriptor.version}),
|
|
550
|
+
descriptor:Object.freeze({
|
|
551
|
+
path:APP_BUNDLE_DESCRIPTOR_NAME,
|
|
552
|
+
schemaVersion:descriptor.schemaVersion,
|
|
553
|
+
canonicalSha256:descriptorSha256,
|
|
554
|
+
packageSha256,
|
|
555
|
+
fileSha256:sha256(descriptorBytes),
|
|
556
|
+
bytes:descriptorBytes.length
|
|
557
|
+
}),
|
|
558
|
+
release:Object.freeze({
|
|
559
|
+
path:APP_BUNDLE_RELEASE_PATH,
|
|
560
|
+
schemaVersion:release.schemaVersion,
|
|
561
|
+
builder:release.builder,
|
|
562
|
+
policySha256:release.policySha256,
|
|
563
|
+
manifestSha256:sha256(releaseBytes),
|
|
564
|
+
contentSha256:release.contentSha256,
|
|
565
|
+
fileCount:release.fileCount,
|
|
566
|
+
totalBytes:release.totalBytes
|
|
567
|
+
}),
|
|
568
|
+
payload:Object.freeze({
|
|
569
|
+
root:'payload',
|
|
570
|
+
fileCount:release.fileCount,
|
|
571
|
+
totalBytes:release.totalBytes,
|
|
572
|
+
files:release.files
|
|
573
|
+
})
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
class ArchiveFileWriter{
|
|
578
|
+
constructor(handle,state){
|
|
579
|
+
this.handle=handle;
|
|
580
|
+
this.digest=createHash('sha256');
|
|
581
|
+
this.bytes=0;
|
|
582
|
+
this.state=state;
|
|
583
|
+
this.recordState();
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
recordState(){
|
|
587
|
+
this.state.contentBytes=this.bytes;
|
|
588
|
+
this.state.contentSha256=this.digest.copy().digest('hex');
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
async write(chunk){
|
|
592
|
+
const bytes=Buffer.isBuffer(chunk)?chunk:Buffer.from(chunk);
|
|
593
|
+
let offset=0;
|
|
594
|
+
while(offset<bytes.length){
|
|
595
|
+
const result=await this.handle.write(bytes,offset,bytes.length-offset,null);
|
|
596
|
+
if(result.bytesWritten===0)fail('Archive output stopped accepting bytes.');
|
|
597
|
+
this.digest.update(bytes.subarray(offset,offset+result.bytesWritten));
|
|
598
|
+
this.bytes+=result.bytesWritten;
|
|
599
|
+
offset+=result.bytesWritten;
|
|
600
|
+
this.recordState();
|
|
601
|
+
if(this.bytes>APP_BUNDLE_LIMITS.maxCompressedBytes){
|
|
602
|
+
fail('Compressed bundle exceeds its 512 MiB limit.',ERROR_CODES.policyDenied);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
result(){
|
|
608
|
+
return {bytes:this.bytes,sha256:this.digest.digest('hex')};
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
class Crc32Transform extends Transform{
|
|
613
|
+
constructor(){
|
|
614
|
+
super();
|
|
615
|
+
this.crc=0xffffffff;
|
|
616
|
+
this.bytes=0;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
_transform(chunk,_encoding,callback){
|
|
620
|
+
this.crc=updateCrc32(this.crc,chunk);
|
|
621
|
+
this.bytes=(this.bytes+chunk.length)>>>0;
|
|
622
|
+
callback(null,chunk);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
trailer(){
|
|
626
|
+
const trailer=Buffer.alloc(8);
|
|
627
|
+
trailer.writeUInt32LE((this.crc^0xffffffff)>>>0,0);
|
|
628
|
+
trailer.writeUInt32LE(this.bytes>>>0,4);
|
|
629
|
+
return trailer;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
async function* tarChunks(entries,{releaseRoot,signal,onEvent}={}){
|
|
634
|
+
for(let index=0;index<entries.length;index+=1){
|
|
635
|
+
throwIfAborted(signal);
|
|
636
|
+
const entry=entries[index];
|
|
637
|
+
yield createCanonicalUstarHeader(entry.path,entry.bytes);
|
|
638
|
+
if(entry.buffer){
|
|
639
|
+
yield entry.buffer;
|
|
640
|
+
}else{
|
|
641
|
+
yield* verifiedFileChunks(releaseRoot,entry.identity,{signal});
|
|
642
|
+
}
|
|
643
|
+
const padding=(TAR_BLOCK_BYTES-(entry.bytes%TAR_BLOCK_BYTES))%TAR_BLOCK_BYTES;
|
|
644
|
+
if(padding)yield Buffer.alloc(padding);
|
|
645
|
+
await emit(onEvent,{
|
|
646
|
+
type:'bundle.entry.written',
|
|
647
|
+
path:entry.path,
|
|
648
|
+
bytes:entry.bytes,
|
|
649
|
+
index:index+1,
|
|
650
|
+
entryCount:entries.length
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
yield Buffer.alloc(TAR_END_BYTES);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
async function writeDeterministicGzip(fileHandle,entries,options){
|
|
657
|
+
const writer=new ArchiveFileWriter(fileHandle,options.stagingState);
|
|
658
|
+
await writer.write(GZIP_HEADER);
|
|
659
|
+
const crc=new Crc32Transform();
|
|
660
|
+
const deflate=createDeflateRaw({level:9});
|
|
661
|
+
const sink=new Writable({
|
|
662
|
+
write(chunk,_encoding,callback){
|
|
663
|
+
writer.write(chunk).then(()=>callback(),callback);
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
await pipeline(Readable.from(tarChunks(entries,options)),crc,deflate,sink);
|
|
667
|
+
await writer.write(crc.trailer());
|
|
668
|
+
return Object.freeze(writer.result());
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function validateOutputFilename(outputPath){
|
|
672
|
+
const filename=path.basename(outputPath);
|
|
673
|
+
if(!filename.toLowerCase().endsWith(APP_BUNDLE_EXTENSION)){
|
|
674
|
+
fail(`Bundle output must end in ${APP_BUNDLE_EXTENSION}.`,ERROR_CODES.usage);
|
|
675
|
+
}
|
|
676
|
+
if(filename!==filename.normalize('NFC')||filename.length>255
|
|
677
|
+
||Buffer.byteLength(filename,'utf8')>255||/[. ]$/u.test(filename)
|
|
678
|
+
||/[<>:"/\\|?*\u0000-\u001f\u007f]/u.test(filename)
|
|
679
|
+
||PORTABLE_RESERVED_FILENAME_PATTERN.test(filename)){
|
|
680
|
+
fail('Bundle output filename must be one portable direct filename.',ERROR_CODES.policyDenied);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
async function acquireArtifactLock(outputPath,{onEvent}={}){
|
|
685
|
+
const lockPath=`${outputPath}.lock`;
|
|
686
|
+
const nonce=randomBytes(16).toString('hex');
|
|
687
|
+
let handle;
|
|
688
|
+
let lockIdentity;
|
|
689
|
+
let documentBytes;
|
|
690
|
+
let created=false;
|
|
691
|
+
try{
|
|
692
|
+
handle=await open(lockPath,'wx+',0o600);
|
|
693
|
+
created=true;
|
|
694
|
+
const acquiredAtUtc=new Date().toISOString();
|
|
695
|
+
const expiresAtUtc=new Date(Date.now()+2*60*60*1000).toISOString();
|
|
696
|
+
documentBytes=Buffer.from(`${JSON.stringify({
|
|
697
|
+
owner:`arcane-os:${process.pid}`,
|
|
698
|
+
nonce,
|
|
699
|
+
artifactPath:outputPath,
|
|
700
|
+
acquiredAtUtc,
|
|
701
|
+
ttlSeconds:7200,
|
|
702
|
+
expiresAtUtc,
|
|
703
|
+
releaseProcedure:'The owning Arcane bundle operation removes this lock after success or failure.',
|
|
704
|
+
staleRecoveryProcedure:'After expiresAtUtc, confirm the recorded process is absent and preserve any adjacent temporary artifact before removing this lock.'
|
|
705
|
+
},null,2)}\n`,'utf8');
|
|
706
|
+
await handle.writeFile(documentBytes);
|
|
707
|
+
await handle.sync();
|
|
708
|
+
const opened=await handle.stat({bigint:true});
|
|
709
|
+
if(!opened.isFile()||opened.nlink!==1n||Number(opened.size)!==documentBytes.length){
|
|
710
|
+
fail('Artifact lock changed while it was acquired.',ERROR_CODES.policyDenied);
|
|
711
|
+
}
|
|
712
|
+
lockIdentity=fileIdentity(opened);
|
|
713
|
+
const current=await lstat(lockPath,{bigint:true});
|
|
714
|
+
if(current.isSymbolicLink()||!current.isFile()||current.nlink!==1n
|
|
715
|
+
||!identityMatches(current,lockIdentity)){
|
|
716
|
+
fail('Artifact lock path changed while it was acquired.',ERROR_CODES.policyDenied);
|
|
717
|
+
}
|
|
718
|
+
await emit(onEvent,Object.freeze({type:'bundle.lock.written',lockPath,nonce}));
|
|
719
|
+
lockIdentity=await anchoredSinglePathContentIdentity(
|
|
720
|
+
lockPath,
|
|
721
|
+
handle,
|
|
722
|
+
lockIdentity,
|
|
723
|
+
sha256(documentBytes),
|
|
724
|
+
'Acquired artifact lock'
|
|
725
|
+
);
|
|
726
|
+
await handle.close();
|
|
727
|
+
handle=null;
|
|
728
|
+
}catch(error){
|
|
729
|
+
if(handle&&lockIdentity&&documentBytes){
|
|
730
|
+
try{
|
|
731
|
+
await anchoredSinglePathContentIdentity(
|
|
732
|
+
lockPath,
|
|
733
|
+
handle,
|
|
734
|
+
lockIdentity,
|
|
735
|
+
sha256(documentBytes),
|
|
736
|
+
'Partial artifact lock cleanup'
|
|
737
|
+
);
|
|
738
|
+
await rm(lockPath);
|
|
739
|
+
}catch(cleanupError){
|
|
740
|
+
if(cleanupError?.code!=='ENOENT'){
|
|
741
|
+
appendErrorWarning(
|
|
742
|
+
error,
|
|
743
|
+
`Partial artifact lock was preserved at ${lockPath}; ${cleanupError.message}`
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}else if(created){
|
|
748
|
+
appendErrorWarning(
|
|
749
|
+
error,
|
|
750
|
+
`Partial artifact lock was preserved at ${lockPath} because its FileHandle identity was unavailable; inspect its nonce and owner before recovery.`
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
if(handle){
|
|
754
|
+
try{
|
|
755
|
+
await handle.close();
|
|
756
|
+
}catch(closeError){
|
|
757
|
+
appendErrorWarning(
|
|
758
|
+
error,
|
|
759
|
+
`Lock acquisition handle close warning: ${String(closeError?.message??closeError)}`
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
handle=null;
|
|
763
|
+
}
|
|
764
|
+
if(error?.code==='EEXIST'){
|
|
765
|
+
fail(`Another bundle operation owns ${lockPath}. Inspect it before stale-lock recovery.`,ERROR_CODES.policyDenied);
|
|
766
|
+
}
|
|
767
|
+
throw error;
|
|
768
|
+
}
|
|
769
|
+
let released=false;
|
|
770
|
+
return async()=>{
|
|
771
|
+
if(released)return null;
|
|
772
|
+
released=true;
|
|
773
|
+
try{
|
|
774
|
+
const current=await lstat(lockPath,{bigint:true});
|
|
775
|
+
if(current.isSymbolicLink()||!current.isFile()||current.nlink!==1n
|
|
776
|
+
||!identityMatches(current,lockIdentity)){
|
|
777
|
+
throw new Error('Artifact lock path no longer belongs to this operation.');
|
|
778
|
+
}
|
|
779
|
+
const opened=await openStableFile(lockPath,'artifact operation lock',{
|
|
780
|
+
maximum:APP_BUNDLE_LIMITS.maxControlBytes
|
|
781
|
+
});
|
|
782
|
+
try{
|
|
783
|
+
if(!identitiesEqual(opened.identity,lockIdentity)){
|
|
784
|
+
throw new Error('Artifact lock identity no longer belongs to this operation.');
|
|
785
|
+
}
|
|
786
|
+
const currentBytes=await readExactOpenedBytes(opened,'artifact operation lock');
|
|
787
|
+
if(!currentBytes.equals(documentBytes)){
|
|
788
|
+
throw new Error('Artifact lock nonce or contents changed before release.');
|
|
789
|
+
}
|
|
790
|
+
}finally{
|
|
791
|
+
await opened.handle.close().catch(()=>{});
|
|
792
|
+
}
|
|
793
|
+
const beforeRemove=await lstat(lockPath,{bigint:true});
|
|
794
|
+
if(!identityMatches(beforeRemove,lockIdentity)){
|
|
795
|
+
throw new Error('Artifact lock changed immediately before release.');
|
|
796
|
+
}
|
|
797
|
+
await rm(lockPath);
|
|
798
|
+
return null;
|
|
799
|
+
}catch(error){
|
|
800
|
+
return Object.freeze({
|
|
801
|
+
scope:'artifact-lock',
|
|
802
|
+
path:lockPath,
|
|
803
|
+
message:String(error?.message??error),
|
|
804
|
+
recovery:'Inspect the lock owner and nonce before removing or replacing the lease.'
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
async function inspectOutput(outputPath,{overwrite,signal}){
|
|
811
|
+
try{
|
|
812
|
+
const info=await lstat(outputPath,{bigint:true});
|
|
813
|
+
if(info.isSymbolicLink()||!info.isFile()||info.nlink!==1n){
|
|
814
|
+
fail('Bundle output collision is not a replaceable regular file.',ERROR_CODES.policyDenied);
|
|
815
|
+
}
|
|
816
|
+
if(!overwrite){
|
|
817
|
+
fail('Bundle output already exists; pass overwrite: true or --overwrite to replace it.',ERROR_CODES.policyDenied);
|
|
818
|
+
}
|
|
819
|
+
const opened=await openStableFile(outputPath,'existing bundle output',{
|
|
820
|
+
maximum:APP_BUNDLE_LIMITS.maxCompressedBytes
|
|
821
|
+
});
|
|
822
|
+
try{
|
|
823
|
+
if(!identitiesEqual(opened.identity,fileIdentity(info))){
|
|
824
|
+
fail('Bundle output changed while its overwrite anchor was opened.',ERROR_CODES.policyDenied);
|
|
825
|
+
}
|
|
826
|
+
const inspected=await anchoredFileSha256(
|
|
827
|
+
opened.handle,
|
|
828
|
+
opened.identity,
|
|
829
|
+
'Existing bundle output',
|
|
830
|
+
{signal}
|
|
831
|
+
);
|
|
832
|
+
if(!identityMatches(inspected.before,opened.identity)
|
|
833
|
+
||!identityMatches(inspected.after,opened.identity)){
|
|
834
|
+
fail('Bundle output changed while its overwrite content was inspected.',ERROR_CODES.policyDenied);
|
|
835
|
+
}
|
|
836
|
+
return {...opened,contentSha256:inspected.sha256};
|
|
837
|
+
}catch(error){
|
|
838
|
+
try{
|
|
839
|
+
await opened.handle.close();
|
|
840
|
+
}catch(closeError){
|
|
841
|
+
appendErrorWarning(
|
|
842
|
+
error,
|
|
843
|
+
`Prior-output anchor close warning: ${String(closeError?.message??closeError)}`
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
throw error;
|
|
847
|
+
}
|
|
848
|
+
}catch(error){
|
|
849
|
+
if(error?.code==='ENOENT')return null;
|
|
850
|
+
throw error;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
async function assertSingleLinkStaging(temporary,temporaryIdentity){
|
|
855
|
+
const staged=await lstat(temporary,{bigint:true});
|
|
856
|
+
if(staged.isSymbolicLink()||!staged.isFile()||staged.nlink!==1n
|
|
857
|
+
||!identityMatches(staged,temporaryIdentity)){
|
|
858
|
+
fail('Verified bundle staging changed before atomic promotion.',ERROR_CODES.policyDenied);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
async function assertLinkedStagingPair(temporary,outputPath,temporaryIdentity,anchorHandle){
|
|
863
|
+
const [staged,output,anchored]=await Promise.all([
|
|
864
|
+
lstat(temporary,{bigint:true}),
|
|
865
|
+
lstat(outputPath,{bigint:true}),
|
|
866
|
+
anchorHandle.stat({bigint:true})
|
|
867
|
+
]);
|
|
868
|
+
const stagedIdentity=fileIdentity(staged);
|
|
869
|
+
if(staged.isSymbolicLink()||output.isSymbolicLink()
|
|
870
|
+
||!staged.isFile()||!output.isFile()||!anchored.isFile()
|
|
871
|
+
||staged.nlink!==2n||output.nlink!==2n
|
|
872
|
+
||anchored.nlink!==2n
|
|
873
|
+
||!identityMatches(output,stagedIdentity)
|
|
874
|
+
||!identityMatches(anchored,stagedIdentity)
|
|
875
|
+
||!fileObjectMatches(staged,temporaryIdentity)
|
|
876
|
+
||!fileObjectMatches(output,temporaryIdentity)
|
|
877
|
+
||Number(staged.size)!==temporaryIdentity.bytes
|
|
878
|
+
||Number(output.size)!==temporaryIdentity.bytes
|
|
879
|
+
||String(staged.mtimeNs)!==temporaryIdentity.modifiedNanoseconds
|
|
880
|
+
||String(output.mtimeNs)!==temporaryIdentity.modifiedNanoseconds){
|
|
881
|
+
fail('Bundle output and staging links did not retain the verified staging object.');
|
|
882
|
+
}
|
|
883
|
+
return stagedIdentity;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
async function anchoredSinglePathIdentity(filePath,handle,priorIdentity,label){
|
|
887
|
+
const [current,anchored]=await Promise.all([
|
|
888
|
+
lstat(filePath,{bigint:true}),
|
|
889
|
+
handle.stat({bigint:true})
|
|
890
|
+
]);
|
|
891
|
+
const currentIdentity=fileIdentity(current);
|
|
892
|
+
if(current.isSymbolicLink()||!current.isFile()||current.nlink!==1n
|
|
893
|
+
||!anchored.isFile()||anchored.nlink!==1n
|
|
894
|
+
||!identityMatches(anchored,currentIdentity)
|
|
895
|
+
||(priorIdentity&&(priorIdentity.links==='1'
|
|
896
|
+
?!identityMatches(current,priorIdentity)
|
|
897
|
+
:(!fileObjectMatches(current,priorIdentity)
|
|
898
|
+
||Number(current.size)!==priorIdentity.bytes
|
|
899
|
+
||String(current.mtimeNs)!==priorIdentity.modifiedNanoseconds)))){
|
|
900
|
+
fail(`${label} did not retain its anchored single-link identity.`);
|
|
901
|
+
}
|
|
902
|
+
return currentIdentity;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
async function anchoredLinkPairIdentity(firstPath,secondPath,handle,priorIdentity,label){
|
|
906
|
+
const [first,second,anchored]=await Promise.all([
|
|
907
|
+
lstat(firstPath,{bigint:true}),
|
|
908
|
+
lstat(secondPath,{bigint:true}),
|
|
909
|
+
handle.stat({bigint:true})
|
|
910
|
+
]);
|
|
911
|
+
const linkedIdentity=fileIdentity(first);
|
|
912
|
+
if(first.isSymbolicLink()||second.isSymbolicLink()
|
|
913
|
+
||!first.isFile()||!second.isFile()||!anchored.isFile()
|
|
914
|
+
||first.nlink!==2n||second.nlink!==2n||anchored.nlink!==2n
|
|
915
|
+
||!identityMatches(second,linkedIdentity)
|
|
916
|
+
||!identityMatches(anchored,linkedIdentity)
|
|
917
|
+
||(priorIdentity.links==='2'
|
|
918
|
+
?!identityMatches(first,priorIdentity)
|
|
919
|
+
:(!fileObjectMatches(first,priorIdentity)
|
|
920
|
+
||Number(first.size)!==priorIdentity.bytes
|
|
921
|
+
||String(first.mtimeNs)!==priorIdentity.modifiedNanoseconds))){
|
|
922
|
+
fail(`${label} did not retain its anchored two-link identity.`);
|
|
923
|
+
}
|
|
924
|
+
return linkedIdentity;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
async function anchoredSinglePathContentIdentity(
|
|
928
|
+
filePath,
|
|
929
|
+
handle,
|
|
930
|
+
identity,
|
|
931
|
+
expectedSha256,
|
|
932
|
+
label,
|
|
933
|
+
options
|
|
934
|
+
){
|
|
935
|
+
await assertAnchoredContent(handle,identity,expectedSha256,label,options);
|
|
936
|
+
const current=await anchoredSinglePathIdentity(filePath,handle,identity,label);
|
|
937
|
+
throwIfAborted(options?.signal);
|
|
938
|
+
return current;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
async function anchoredLinkPairContentIdentity(
|
|
942
|
+
firstPath,
|
|
943
|
+
secondPath,
|
|
944
|
+
handle,
|
|
945
|
+
identity,
|
|
946
|
+
expectedSha256,
|
|
947
|
+
label,
|
|
948
|
+
options
|
|
949
|
+
){
|
|
950
|
+
await assertAnchoredContent(handle,identity,expectedSha256,label,options);
|
|
951
|
+
const current=await anchoredLinkPairIdentity(firstPath,secondPath,handle,identity,label);
|
|
952
|
+
throwIfAborted(options?.signal);
|
|
953
|
+
return current;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function changedStagingCleanupIssue(temporary,message){
|
|
957
|
+
return Object.freeze({
|
|
958
|
+
scope:'artifact-staging',
|
|
959
|
+
path:temporary,
|
|
960
|
+
message,
|
|
961
|
+
recovery:'Inspect the preserved staging path and remove it only after confirming its owner.'
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function anchoredCleanupResult({issue=null,retryIdentity=null}={}){
|
|
966
|
+
return Object.freeze({issue,retryIdentity});
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
async function cleanupAnchoredTemporary(
|
|
970
|
+
temporary,
|
|
971
|
+
handle,
|
|
972
|
+
expectedIdentity,
|
|
973
|
+
expectedContentSha256,
|
|
974
|
+
expectedContentBytes
|
|
975
|
+
){
|
|
976
|
+
try{
|
|
977
|
+
const anchored=await handle.stat({bigint:true});
|
|
978
|
+
let current;
|
|
979
|
+
try{
|
|
980
|
+
current=await lstat(temporary,{bigint:true});
|
|
981
|
+
}catch(error){
|
|
982
|
+
if(error?.code==='ENOENT')return anchoredCleanupResult();
|
|
983
|
+
throw error;
|
|
984
|
+
}
|
|
985
|
+
let anchoredIdentity=fileIdentity(anchored);
|
|
986
|
+
if(!anchored.isFile()||anchored.nlink!==1n
|
|
987
|
+
||current.isSymbolicLink()||!current.isFile()||current.nlink!==1n
|
|
988
|
+
||!fileObjectMatches(current,anchoredIdentity)
|
|
989
|
+
||Number(current.size)!==anchoredIdentity.bytes){
|
|
990
|
+
return anchoredCleanupResult({
|
|
991
|
+
issue:changedStagingCleanupIssue(
|
|
992
|
+
temporary,
|
|
993
|
+
`Preserved changed staging path ${temporary}; it is not the exact file object held by the creation handle.`
|
|
994
|
+
)
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
if(expectedContentSha256&&Number.isSafeInteger(expectedContentBytes)
|
|
998
|
+
&&expectedContentBytes>=0){
|
|
999
|
+
const contentIdentity=expectedIdentity??Object.freeze({
|
|
1000
|
+
...anchoredIdentity,
|
|
1001
|
+
bytes:expectedContentBytes
|
|
1002
|
+
});
|
|
1003
|
+
try{
|
|
1004
|
+
anchoredIdentity=await anchoredSinglePathContentIdentity(
|
|
1005
|
+
temporary,
|
|
1006
|
+
handle,
|
|
1007
|
+
contentIdentity,
|
|
1008
|
+
expectedContentSha256,
|
|
1009
|
+
'Verified bundle staging cleanup'
|
|
1010
|
+
);
|
|
1011
|
+
}catch(error){
|
|
1012
|
+
return anchoredCleanupResult({
|
|
1013
|
+
issue:changedStagingCleanupIssue(
|
|
1014
|
+
temporary,
|
|
1015
|
+
`Preserved changed staging path ${temporary}; ${error.message}`
|
|
1016
|
+
)
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
}else{
|
|
1020
|
+
return anchoredCleanupResult({
|
|
1021
|
+
issue:changedStagingCleanupIssue(
|
|
1022
|
+
temporary,
|
|
1023
|
+
`Preserved staging path ${temporary}; no authoritative content identity was available for cleanup.`
|
|
1024
|
+
)
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
try{
|
|
1028
|
+
await rm(temporary);
|
|
1029
|
+
}catch(error){
|
|
1030
|
+
if(['EACCES','EBUSY','EPERM'].includes(error?.code)){
|
|
1031
|
+
return anchoredCleanupResult({retryIdentity:anchoredIdentity});
|
|
1032
|
+
}
|
|
1033
|
+
throw error;
|
|
1034
|
+
}
|
|
1035
|
+
try{
|
|
1036
|
+
await lstat(temporary,{bigint:true});
|
|
1037
|
+
return anchoredCleanupResult({
|
|
1038
|
+
issue:changedStagingCleanupIssue(
|
|
1039
|
+
temporary,
|
|
1040
|
+
`A staging path reappeared after anchored cleanup at ${temporary}.`
|
|
1041
|
+
)
|
|
1042
|
+
});
|
|
1043
|
+
}catch(error){
|
|
1044
|
+
if(error?.code==='ENOENT')return anchoredCleanupResult();
|
|
1045
|
+
throw error;
|
|
1046
|
+
}
|
|
1047
|
+
}catch(error){
|
|
1048
|
+
return anchoredCleanupResult({
|
|
1049
|
+
issue:changedStagingCleanupIssue(
|
|
1050
|
+
temporary,
|
|
1051
|
+
`Anchored staging cleanup warning for ${temporary}: ${String(error?.message??error)}`
|
|
1052
|
+
)
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
async function cleanupOwnedTemporary(
|
|
1058
|
+
temporary,
|
|
1059
|
+
temporaryIdentity,
|
|
1060
|
+
expectedContentSha256,
|
|
1061
|
+
expectedContentBytes
|
|
1062
|
+
){
|
|
1063
|
+
let opened;
|
|
1064
|
+
const closeAnchor=async message=>{
|
|
1065
|
+
if(!opened?.handle)return message;
|
|
1066
|
+
const handle=opened.handle;
|
|
1067
|
+
opened=null;
|
|
1068
|
+
try{
|
|
1069
|
+
await handle.close();
|
|
1070
|
+
return message;
|
|
1071
|
+
}catch(error){
|
|
1072
|
+
const warning=`Retry-cleanup anchor close warning: ${String(error?.message??error)}`;
|
|
1073
|
+
return message?`${message} ${warning}`:warning;
|
|
1074
|
+
}
|
|
1075
|
+
};
|
|
1076
|
+
try{
|
|
1077
|
+
const current=await lstat(temporary,{bigint:true});
|
|
1078
|
+
if(!temporaryIdentity||!expectedContentSha256
|
|
1079
|
+
||!Number.isSafeInteger(expectedContentBytes)||expectedContentBytes<0
|
|
1080
|
+
||current.isSymbolicLink()||!current.isFile()||current.nlink!==1n
|
|
1081
|
+
||!fileObjectMatches(current,temporaryIdentity)
|
|
1082
|
+
||Number(current.size)!==temporaryIdentity.bytes
|
|
1083
|
+
||temporaryIdentity.bytes!==expectedContentBytes){
|
|
1084
|
+
return changedStagingCleanupIssue(
|
|
1085
|
+
temporary,
|
|
1086
|
+
`Preserved changed staging path ${temporary}; its complete no-follow single-link identity is not owned by this operation.`
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
opened=await openStableFile(temporary,'verified bundle staging retry cleanup',{
|
|
1090
|
+
maximum:APP_BUNDLE_LIMITS.maxCompressedBytes
|
|
1091
|
+
});
|
|
1092
|
+
if(!fileObjectMatches(current,opened.identity)
|
|
1093
|
+
||!fileObjectMatches(current,temporaryIdentity)
|
|
1094
|
+
||opened.identity.bytes!==temporaryIdentity.bytes){
|
|
1095
|
+
return changedStagingCleanupIssue(
|
|
1096
|
+
temporary,
|
|
1097
|
+
await closeAnchor(
|
|
1098
|
+
`Preserved changed staging path ${temporary}; its retry-cleanup file object is not owned by this operation.`
|
|
1099
|
+
)
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
try{
|
|
1103
|
+
await assertAnchoredContent(
|
|
1104
|
+
opened.handle,
|
|
1105
|
+
temporaryIdentity,
|
|
1106
|
+
expectedContentSha256,
|
|
1107
|
+
'Verified bundle staging retry cleanup'
|
|
1108
|
+
);
|
|
1109
|
+
}catch(error){
|
|
1110
|
+
return changedStagingCleanupIssue(
|
|
1111
|
+
temporary,
|
|
1112
|
+
await closeAnchor(
|
|
1113
|
+
`Preserved changed staging path ${temporary}; ${error.message}`
|
|
1114
|
+
)
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
const closeWarning=await closeAnchor(null);
|
|
1118
|
+
if(closeWarning){
|
|
1119
|
+
return changedStagingCleanupIssue(
|
|
1120
|
+
temporary,
|
|
1121
|
+
`Preserved changed staging path ${temporary}; ${closeWarning}`
|
|
1122
|
+
);
|
|
1123
|
+
}
|
|
1124
|
+
const beforeRemove=await lstat(temporary,{bigint:true});
|
|
1125
|
+
if(beforeRemove.isSymbolicLink()||!beforeRemove.isFile()||beforeRemove.nlink!==1n
|
|
1126
|
+
||!identityMatches(beforeRemove,temporaryIdentity)){
|
|
1127
|
+
return changedStagingCleanupIssue(
|
|
1128
|
+
temporary,
|
|
1129
|
+
`Preserved changed staging path ${temporary}; its retry-cleanup identity changed before removal.`
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
await rm(temporary);
|
|
1133
|
+
try{
|
|
1134
|
+
await lstat(temporary,{bigint:true});
|
|
1135
|
+
return changedStagingCleanupIssue(
|
|
1136
|
+
temporary,
|
|
1137
|
+
`A staging path reappeared after exact-identity cleanup at ${temporary}.`
|
|
1138
|
+
);
|
|
1139
|
+
}catch(error){
|
|
1140
|
+
if(error?.code==='ENOENT')return null;
|
|
1141
|
+
throw error;
|
|
1142
|
+
}
|
|
1143
|
+
}catch(error){
|
|
1144
|
+
const closeWarning=await closeAnchor(null);
|
|
1145
|
+
if(error?.code==='ENOENT'&&!closeWarning)return null;
|
|
1146
|
+
return changedStagingCleanupIssue(
|
|
1147
|
+
temporary,
|
|
1148
|
+
`Staging cleanup warning for ${temporary}: ${String(error?.message??error)}`
|
|
1149
|
+
+(closeWarning?` ${closeWarning}`:'')
|
|
1150
|
+
);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
async function promoteArtifact(temporary,outputPath,{
|
|
1155
|
+
existingOutput,
|
|
1156
|
+
stagingState,
|
|
1157
|
+
anchorHandle,
|
|
1158
|
+
onEvent,
|
|
1159
|
+
signal
|
|
1160
|
+
}){
|
|
1161
|
+
await assertAnchoredContent(
|
|
1162
|
+
anchorHandle,
|
|
1163
|
+
stagingState.identity,
|
|
1164
|
+
stagingState.contentSha256,
|
|
1165
|
+
'Verified bundle staging',
|
|
1166
|
+
{signal}
|
|
1167
|
+
);
|
|
1168
|
+
await assertSingleLinkStaging(temporary,stagingState.identity);
|
|
1169
|
+
if(!existingOutput){
|
|
1170
|
+
let promoted=false;
|
|
1171
|
+
try{
|
|
1172
|
+
await emit(onEvent,{
|
|
1173
|
+
type:'bundle.archive.output-vacated',
|
|
1174
|
+
outputPath,
|
|
1175
|
+
replaced:false
|
|
1176
|
+
});
|
|
1177
|
+
await assertAnchoredContent(
|
|
1178
|
+
anchorHandle,
|
|
1179
|
+
stagingState.identity,
|
|
1180
|
+
stagingState.contentSha256,
|
|
1181
|
+
'Verified bundle staging',
|
|
1182
|
+
{signal}
|
|
1183
|
+
);
|
|
1184
|
+
await assertSingleLinkStaging(temporary,stagingState.identity);
|
|
1185
|
+
throwIfAborted(signal);
|
|
1186
|
+
await link(temporary,outputPath);
|
|
1187
|
+
promoted=true;
|
|
1188
|
+
stagingState.identity=await assertLinkedStagingPair(
|
|
1189
|
+
temporary,
|
|
1190
|
+
outputPath,
|
|
1191
|
+
stagingState.identity,
|
|
1192
|
+
anchorHandle
|
|
1193
|
+
);
|
|
1194
|
+
throwIfAborted(signal);
|
|
1195
|
+
await rm(temporary);
|
|
1196
|
+
stagingState.identity=await anchoredSinglePathIdentity(
|
|
1197
|
+
outputPath,
|
|
1198
|
+
anchorHandle,
|
|
1199
|
+
stagingState.identity,
|
|
1200
|
+
'Bundle output'
|
|
1201
|
+
);
|
|
1202
|
+
return Object.freeze({
|
|
1203
|
+
outputPath,
|
|
1204
|
+
backupPath:null,
|
|
1205
|
+
backupIdentity:null,
|
|
1206
|
+
backupContentSha256:null,
|
|
1207
|
+
promotedIdentity:stagingState.identity,
|
|
1208
|
+
promotedContentSha256:stagingState.contentSha256,
|
|
1209
|
+
promoted,
|
|
1210
|
+
replaced:false,
|
|
1211
|
+
stagingPath:temporary,
|
|
1212
|
+
stagingState
|
|
1213
|
+
});
|
|
1214
|
+
}catch(error){
|
|
1215
|
+
const failure=error?.code==='EEXIST'
|
|
1216
|
+
?new ArcaneError(
|
|
1217
|
+
ERROR_CODES.policyDenied,
|
|
1218
|
+
'Bundle output appeared before create-only promotion; no file was overwritten.',
|
|
1219
|
+
{cause:error}
|
|
1220
|
+
)
|
|
1221
|
+
:error;
|
|
1222
|
+
if(promoted){
|
|
1223
|
+
const rollbackIssues=await rollbackPromotion({
|
|
1224
|
+
outputPath,
|
|
1225
|
+
backupPath:null,
|
|
1226
|
+
backupIdentity:null,
|
|
1227
|
+
backupContentSha256:null,
|
|
1228
|
+
promotedIdentity:stagingState.identity,
|
|
1229
|
+
promotedContentSha256:stagingState.contentSha256,
|
|
1230
|
+
promoted,
|
|
1231
|
+
stagingPath:temporary,
|
|
1232
|
+
stagingState
|
|
1233
|
+
},{promotedHandle:anchorHandle});
|
|
1234
|
+
if(rollbackIssues.length){
|
|
1235
|
+
appendErrorWarning(failure,`Rollback warning: ${rollbackIssues.join('; ')}`);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
throw failure;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
const existingIdentity=existingOutput.identity;
|
|
1242
|
+
const backup=`${outputPath}.backup-${process.pid}-${randomBytes(6).toString('hex')}`;
|
|
1243
|
+
let backupLinked=false;
|
|
1244
|
+
let outputVacated=false;
|
|
1245
|
+
let promoted=false;
|
|
1246
|
+
let backupIdentity=null;
|
|
1247
|
+
const backupContentSha256=existingOutput.contentSha256;
|
|
1248
|
+
try{
|
|
1249
|
+
const beforeBackup=await anchoredSinglePathContentIdentity(
|
|
1250
|
+
outputPath,
|
|
1251
|
+
existingOutput.handle,
|
|
1252
|
+
existingIdentity,
|
|
1253
|
+
backupContentSha256,
|
|
1254
|
+
'Existing bundle output',
|
|
1255
|
+
{signal}
|
|
1256
|
+
);
|
|
1257
|
+
if(!identitiesEqual(beforeBackup,existingIdentity)){
|
|
1258
|
+
fail('Bundle output changed before atomic promotion; no file was overwritten.',ERROR_CODES.policyDenied);
|
|
1259
|
+
}
|
|
1260
|
+
throwIfAborted(signal);
|
|
1261
|
+
await link(outputPath,backup);
|
|
1262
|
+
backupLinked=true;
|
|
1263
|
+
existingOutput.identity=await anchoredLinkPairIdentity(
|
|
1264
|
+
outputPath,
|
|
1265
|
+
backup,
|
|
1266
|
+
existingOutput.handle,
|
|
1267
|
+
existingOutput.identity,
|
|
1268
|
+
'Bundle output backup'
|
|
1269
|
+
);
|
|
1270
|
+
await emit(onEvent,{
|
|
1271
|
+
type:'bundle.archive.backup-linked',
|
|
1272
|
+
outputPath,
|
|
1273
|
+
backupPath:backup
|
|
1274
|
+
});
|
|
1275
|
+
existingOutput.identity=await anchoredLinkPairContentIdentity(
|
|
1276
|
+
outputPath,
|
|
1277
|
+
backup,
|
|
1278
|
+
existingOutput.handle,
|
|
1279
|
+
existingOutput.identity,
|
|
1280
|
+
backupContentSha256,
|
|
1281
|
+
'Bundle output backup',
|
|
1282
|
+
{signal}
|
|
1283
|
+
);
|
|
1284
|
+
throwIfAborted(signal);
|
|
1285
|
+
await rm(outputPath);
|
|
1286
|
+
outputVacated=true;
|
|
1287
|
+
existingOutput.identity=await anchoredSinglePathIdentity(
|
|
1288
|
+
backup,
|
|
1289
|
+
existingOutput.handle,
|
|
1290
|
+
existingOutput.identity,
|
|
1291
|
+
'Vacated bundle output backup'
|
|
1292
|
+
);
|
|
1293
|
+
backupIdentity=existingOutput.identity;
|
|
1294
|
+
await emit(onEvent,{
|
|
1295
|
+
type:'bundle.archive.output-vacated',
|
|
1296
|
+
outputPath,
|
|
1297
|
+
backupPath:backup,
|
|
1298
|
+
replaced:true
|
|
1299
|
+
});
|
|
1300
|
+
const stagedAfterEvent=await anchoredSinglePathContentIdentity(
|
|
1301
|
+
temporary,
|
|
1302
|
+
anchorHandle,
|
|
1303
|
+
stagingState.identity,
|
|
1304
|
+
stagingState.contentSha256,
|
|
1305
|
+
'Verified bundle staging',
|
|
1306
|
+
{signal}
|
|
1307
|
+
);
|
|
1308
|
+
if(!identitiesEqual(stagedAfterEvent,stagingState.identity)){
|
|
1309
|
+
fail('Verified bundle staging changed before overwrite promotion.',ERROR_CODES.policyDenied);
|
|
1310
|
+
}
|
|
1311
|
+
const backupAfterEvent=await anchoredSinglePathContentIdentity(
|
|
1312
|
+
backup,
|
|
1313
|
+
existingOutput.handle,
|
|
1314
|
+
backupIdentity,
|
|
1315
|
+
backupContentSha256,
|
|
1316
|
+
'Preserved bundle backup',
|
|
1317
|
+
{signal}
|
|
1318
|
+
);
|
|
1319
|
+
if(!identitiesEqual(backupAfterEvent,backupIdentity)){
|
|
1320
|
+
fail('Preserved bundle backup changed before overwrite promotion.',ERROR_CODES.policyDenied);
|
|
1321
|
+
}
|
|
1322
|
+
const reboundStaging=await anchoredSinglePathIdentity(
|
|
1323
|
+
temporary,
|
|
1324
|
+
anchorHandle,
|
|
1325
|
+
stagedAfterEvent,
|
|
1326
|
+
'Verified bundle staging'
|
|
1327
|
+
);
|
|
1328
|
+
const reboundBackup=await anchoredSinglePathIdentity(
|
|
1329
|
+
backup,
|
|
1330
|
+
existingOutput.handle,
|
|
1331
|
+
backupAfterEvent,
|
|
1332
|
+
'Preserved bundle backup'
|
|
1333
|
+
);
|
|
1334
|
+
if(!identitiesEqual(reboundStaging,stagedAfterEvent)
|
|
1335
|
+
||!identitiesEqual(reboundBackup,backupAfterEvent)){
|
|
1336
|
+
fail('Bundle staging or preserved backup changed immediately before promotion.',ERROR_CODES.policyDenied);
|
|
1337
|
+
}
|
|
1338
|
+
throwIfAborted(signal);
|
|
1339
|
+
await link(temporary,outputPath);
|
|
1340
|
+
promoted=true;
|
|
1341
|
+
stagingState.identity=await assertLinkedStagingPair(
|
|
1342
|
+
temporary,
|
|
1343
|
+
outputPath,
|
|
1344
|
+
stagingState.identity,
|
|
1345
|
+
anchorHandle
|
|
1346
|
+
);
|
|
1347
|
+
throwIfAborted(signal);
|
|
1348
|
+
await rm(temporary);
|
|
1349
|
+
stagingState.identity=await anchoredSinglePathIdentity(
|
|
1350
|
+
outputPath,
|
|
1351
|
+
anchorHandle,
|
|
1352
|
+
stagingState.identity,
|
|
1353
|
+
'Bundle output'
|
|
1354
|
+
);
|
|
1355
|
+
return Object.freeze({
|
|
1356
|
+
outputPath,
|
|
1357
|
+
backupPath:backup,
|
|
1358
|
+
backupIdentity,
|
|
1359
|
+
backupContentSha256,
|
|
1360
|
+
promotedIdentity:stagingState.identity,
|
|
1361
|
+
promotedContentSha256:stagingState.contentSha256,
|
|
1362
|
+
promoted,
|
|
1363
|
+
replaced:true,
|
|
1364
|
+
stagingPath:temporary,
|
|
1365
|
+
stagingState
|
|
1366
|
+
});
|
|
1367
|
+
}catch(error){
|
|
1368
|
+
const failure=error?.code==='EEXIST'
|
|
1369
|
+
?new ArcaneError(
|
|
1370
|
+
ERROR_CODES.policyDenied,
|
|
1371
|
+
'A path collision blocked create-only bundle promotion; no path was overwritten.',
|
|
1372
|
+
{cause:error}
|
|
1373
|
+
)
|
|
1374
|
+
:error;
|
|
1375
|
+
if(outputVacated||promoted){
|
|
1376
|
+
const rollbackIssues=await rollbackPromotion({
|
|
1377
|
+
outputPath,
|
|
1378
|
+
backupPath:backupLinked?backup:null,
|
|
1379
|
+
backupIdentity,
|
|
1380
|
+
backupContentSha256,
|
|
1381
|
+
promotedIdentity:stagingState.identity,
|
|
1382
|
+
promotedContentSha256:stagingState.contentSha256,
|
|
1383
|
+
promoted,
|
|
1384
|
+
stagingPath:temporary,
|
|
1385
|
+
stagingState
|
|
1386
|
+
},{
|
|
1387
|
+
promotedHandle:anchorHandle,
|
|
1388
|
+
backupHandle:existingOutput.handle
|
|
1389
|
+
});
|
|
1390
|
+
if(rollbackIssues.length){
|
|
1391
|
+
appendErrorWarning(failure,`Rollback warning: ${rollbackIssues.join('; ')}`);
|
|
1392
|
+
}
|
|
1393
|
+
}else if(backupLinked){
|
|
1394
|
+
const cleanupIssues=await removeUncommittedBackupLink({
|
|
1395
|
+
outputPath,
|
|
1396
|
+
backupPath:backup,
|
|
1397
|
+
existingOutput
|
|
1398
|
+
});
|
|
1399
|
+
if(cleanupIssues.length){
|
|
1400
|
+
appendErrorWarning(failure,`Backup cleanup warning: ${cleanupIssues.join('; ')}`);
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
throw failure;
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
async function removeUncommittedBackupLink({outputPath,backupPath,existingOutput}){
|
|
1408
|
+
const issues=[];
|
|
1409
|
+
try{
|
|
1410
|
+
existingOutput.identity=await anchoredLinkPairContentIdentity(
|
|
1411
|
+
outputPath,
|
|
1412
|
+
backupPath,
|
|
1413
|
+
existingOutput.handle,
|
|
1414
|
+
existingOutput.identity,
|
|
1415
|
+
existingOutput.contentSha256,
|
|
1416
|
+
'Uncommitted bundle backup'
|
|
1417
|
+
);
|
|
1418
|
+
await rm(backupPath);
|
|
1419
|
+
existingOutput.identity=await anchoredSinglePathContentIdentity(
|
|
1420
|
+
outputPath,
|
|
1421
|
+
existingOutput.handle,
|
|
1422
|
+
existingOutput.identity,
|
|
1423
|
+
existingOutput.contentSha256,
|
|
1424
|
+
'Existing output after backup cleanup'
|
|
1425
|
+
);
|
|
1426
|
+
}catch(error){
|
|
1427
|
+
issues.push(`preserve uncommitted backup ${backupPath}: ${error.message}`);
|
|
1428
|
+
}
|
|
1429
|
+
return issues;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
async function stableFileDigest(filePath,label,{signal}={}){
|
|
1433
|
+
const opened=await openStableFile(filePath,label,{
|
|
1434
|
+
maximum:APP_BUNDLE_LIMITS.maxCompressedBytes
|
|
1435
|
+
});
|
|
1436
|
+
let operationError;
|
|
1437
|
+
try{
|
|
1438
|
+
const digest=createHash('sha256');
|
|
1439
|
+
const buffer=Buffer.allocUnsafe(64*1024);
|
|
1440
|
+
let position=0;
|
|
1441
|
+
while(position<opened.identity.bytes){
|
|
1442
|
+
throwIfAborted(signal);
|
|
1443
|
+
const requested=Math.min(buffer.length,opened.identity.bytes-position);
|
|
1444
|
+
const result=await opened.handle.read(buffer,0,requested,position);
|
|
1445
|
+
if(result.bytesRead===0)fail(`${label} ended before its recorded byte length.`);
|
|
1446
|
+
digest.update(buffer.subarray(0,result.bytesRead));
|
|
1447
|
+
position+=result.bytesRead;
|
|
1448
|
+
}
|
|
1449
|
+
const probe=Buffer.alloc(1);
|
|
1450
|
+
const extra=await opened.handle.read(probe,0,1,opened.identity.bytes);
|
|
1451
|
+
if(extra.bytesRead!==0)fail(`${label} grew beyond its recorded byte length.`);
|
|
1452
|
+
const canonicalPath=await realpath(filePath);
|
|
1453
|
+
const after=await opened.handle.stat({bigint:true});
|
|
1454
|
+
const current=await lstat(filePath,{bigint:true});
|
|
1455
|
+
const canonical=await lstat(canonicalPath,{bigint:true});
|
|
1456
|
+
if(after.nlink!==1n||current.nlink!==1n||canonical.nlink!==1n
|
|
1457
|
+
||current.isSymbolicLink()||canonical.isSymbolicLink()
|
|
1458
|
+
||!current.isFile()||!identityMatches(after,opened.identity)
|
|
1459
|
+
||!canonical.isFile()||!identityMatches(current,opened.identity)
|
|
1460
|
+
||!identityMatches(canonical,opened.identity)){
|
|
1461
|
+
fail(`${label} changed while its promoted identity was verified.`);
|
|
1462
|
+
}
|
|
1463
|
+
throwIfAborted(signal);
|
|
1464
|
+
return Object.freeze({
|
|
1465
|
+
path:canonicalPath,
|
|
1466
|
+
bytes:opened.identity.bytes,
|
|
1467
|
+
sha256:digest.digest('hex'),
|
|
1468
|
+
identity:opened.identity
|
|
1469
|
+
});
|
|
1470
|
+
}catch(error){
|
|
1471
|
+
operationError=error;
|
|
1472
|
+
throw error;
|
|
1473
|
+
}finally{
|
|
1474
|
+
try{
|
|
1475
|
+
await opened.handle.close();
|
|
1476
|
+
}catch(closeError){
|
|
1477
|
+
if(operationError){
|
|
1478
|
+
appendErrorWarning(
|
|
1479
|
+
operationError,
|
|
1480
|
+
`${label} handle close warning: ${String(closeError?.message??closeError)}`
|
|
1481
|
+
);
|
|
1482
|
+
}else{
|
|
1483
|
+
throw closeError;
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
async function rollbackPromotion(transaction,{promotedHandle,backupHandle}={}){
|
|
1490
|
+
const issues=[];
|
|
1491
|
+
let outputAbsent=false;
|
|
1492
|
+
let recoverableBackupIdentity=null;
|
|
1493
|
+
if(transaction.backupPath){
|
|
1494
|
+
if(!transaction.backupIdentity||!transaction.backupContentSha256||!backupHandle){
|
|
1495
|
+
issues.push(`preserve ${transaction.backupPath}; backup identity is unavailable before restore`);
|
|
1496
|
+
issues.push(`preserve promoted output path ${transaction.outputPath}; prior backup is not recoverable`);
|
|
1497
|
+
return issues;
|
|
1498
|
+
}
|
|
1499
|
+
try{
|
|
1500
|
+
recoverableBackupIdentity=await anchoredSinglePathIdentity(
|
|
1501
|
+
transaction.backupPath,
|
|
1502
|
+
backupHandle,
|
|
1503
|
+
transaction.backupIdentity,
|
|
1504
|
+
'Preserved bundle backup'
|
|
1505
|
+
);
|
|
1506
|
+
if(!identitiesEqual(recoverableBackupIdentity,transaction.backupIdentity)){
|
|
1507
|
+
throw new Error('Preserved bundle backup identity changed before restore.');
|
|
1508
|
+
}
|
|
1509
|
+
await assertAnchoredContent(
|
|
1510
|
+
backupHandle,
|
|
1511
|
+
recoverableBackupIdentity,
|
|
1512
|
+
transaction.backupContentSha256,
|
|
1513
|
+
'Preserved bundle backup'
|
|
1514
|
+
);
|
|
1515
|
+
}catch(error){
|
|
1516
|
+
if(error?.code==='ENOENT'){
|
|
1517
|
+
issues.push(`preserved backup disappeared before restore: ${transaction.backupPath}`);
|
|
1518
|
+
}else{
|
|
1519
|
+
issues.push(`preserve ${transaction.backupPath}; backup identity changed before restore: ${error.message}`);
|
|
1520
|
+
}
|
|
1521
|
+
issues.push(`preserve promoted output path ${transaction.outputPath}; prior backup is not recoverable`);
|
|
1522
|
+
return issues;
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
try{
|
|
1526
|
+
const output=await lstat(transaction.outputPath,{bigint:true});
|
|
1527
|
+
let anchored;
|
|
1528
|
+
if(promotedHandle){
|
|
1529
|
+
try{
|
|
1530
|
+
anchored=await promotedHandle.stat({bigint:true});
|
|
1531
|
+
}catch(error){
|
|
1532
|
+
issues.push(`preserve changed output path ${transaction.outputPath}; promoted handle identity is unavailable: ${error.message}`);
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
let ownedContent=false;
|
|
1536
|
+
if(transaction.promoted&&transaction.promotedIdentity
|
|
1537
|
+
&&transaction.promotedContentSha256&&promotedHandle
|
|
1538
|
+
&&!output.isSymbolicLink()&&output.isFile()
|
|
1539
|
+
&&fileObjectMatches(output,transaction.promotedIdentity)
|
|
1540
|
+
&&Number(output.size)===transaction.promotedIdentity.bytes
|
|
1541
|
+
&&anchored?.isFile()
|
|
1542
|
+
&&fileObjectMatches(anchored,transaction.promotedIdentity)
|
|
1543
|
+
&&Number(anchored.size)===transaction.promotedIdentity.bytes){
|
|
1544
|
+
try{
|
|
1545
|
+
const reboundIdentity=await anchoredSinglePathContentIdentity(
|
|
1546
|
+
transaction.outputPath,
|
|
1547
|
+
promotedHandle,
|
|
1548
|
+
transaction.promotedIdentity,
|
|
1549
|
+
transaction.promotedContentSha256,
|
|
1550
|
+
'Promoted bundle output rollback'
|
|
1551
|
+
);
|
|
1552
|
+
ownedContent=identitiesEqual(reboundIdentity,transaction.promotedIdentity);
|
|
1553
|
+
}catch(error){
|
|
1554
|
+
issues.push(`preserve changed output path ${transaction.outputPath}; ${error.message}`);
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
if(!ownedContent){
|
|
1558
|
+
if(!issues.some(issue=>issue.startsWith(`preserve changed output path ${transaction.outputPath};`))){
|
|
1559
|
+
issues.push(`preserve changed output path ${transaction.outputPath}; identity is not owned by this operation`);
|
|
1560
|
+
}
|
|
1561
|
+
}else{
|
|
1562
|
+
await rm(transaction.outputPath);
|
|
1563
|
+
outputAbsent=true;
|
|
1564
|
+
if(transaction.stagingPath&&transaction.stagingState&&promotedHandle){
|
|
1565
|
+
try{
|
|
1566
|
+
transaction.stagingState.identity=await anchoredSinglePathIdentity(
|
|
1567
|
+
transaction.stagingPath,
|
|
1568
|
+
promotedHandle,
|
|
1569
|
+
transaction.promotedIdentity,
|
|
1570
|
+
'Rolled-back bundle staging'
|
|
1571
|
+
);
|
|
1572
|
+
}catch(error){
|
|
1573
|
+
if(error?.code!=='ENOENT'){
|
|
1574
|
+
issues.push(`refresh rolled-back staging identity: ${error.message}`);
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
}catch(error){
|
|
1580
|
+
if(error?.code==='ENOENT')outputAbsent=true;
|
|
1581
|
+
else issues.push(`inspect or remove failed output: ${error.message}`);
|
|
1582
|
+
}
|
|
1583
|
+
if(transaction.backupPath){
|
|
1584
|
+
try{
|
|
1585
|
+
const backupIdentity=await anchoredSinglePathContentIdentity(
|
|
1586
|
+
transaction.backupPath,
|
|
1587
|
+
backupHandle,
|
|
1588
|
+
recoverableBackupIdentity,
|
|
1589
|
+
transaction.backupContentSha256,
|
|
1590
|
+
'Preserved bundle backup'
|
|
1591
|
+
);
|
|
1592
|
+
if(!identitiesEqual(backupIdentity,recoverableBackupIdentity)){
|
|
1593
|
+
issues.push(`preserve ${transaction.backupPath}; backup identity changed before restore`);
|
|
1594
|
+
return issues;
|
|
1595
|
+
}
|
|
1596
|
+
if(!outputAbsent){
|
|
1597
|
+
issues.push(`preserve ${transaction.backupPath}; output path is occupied by an unowned identity`);
|
|
1598
|
+
return issues;
|
|
1599
|
+
}
|
|
1600
|
+
await link(transaction.backupPath,transaction.outputPath);
|
|
1601
|
+
const linkedIdentity=await anchoredLinkPairContentIdentity(
|
|
1602
|
+
transaction.outputPath,
|
|
1603
|
+
transaction.backupPath,
|
|
1604
|
+
backupHandle,
|
|
1605
|
+
transaction.backupIdentity,
|
|
1606
|
+
transaction.backupContentSha256,
|
|
1607
|
+
'Create-only bundle restore'
|
|
1608
|
+
);
|
|
1609
|
+
await rm(transaction.backupPath);
|
|
1610
|
+
await anchoredSinglePathContentIdentity(
|
|
1611
|
+
transaction.outputPath,
|
|
1612
|
+
backupHandle,
|
|
1613
|
+
linkedIdentity,
|
|
1614
|
+
transaction.backupContentSha256,
|
|
1615
|
+
'Restored bundle output'
|
|
1616
|
+
);
|
|
1617
|
+
}catch(error){
|
|
1618
|
+
if(error?.code==='EEXIST'){
|
|
1619
|
+
issues.push(`preserve ${transaction.backupPath}; create-only restore found an occupied output path`);
|
|
1620
|
+
}else if(error?.code!=='ENOENT'){
|
|
1621
|
+
issues.push(`restore ${transaction.backupPath}: ${error.message}`);
|
|
1622
|
+
}
|
|
1623
|
+
else issues.push(`preserved backup disappeared before restore: ${transaction.backupPath}`);
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
return issues;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
async function finalizePromotion(transaction,{backupHandle}={}){
|
|
1630
|
+
if(!transaction.backupPath)return null;
|
|
1631
|
+
try{
|
|
1632
|
+
if(!transaction.backupIdentity||!transaction.backupContentSha256||!backupHandle){
|
|
1633
|
+
throw new Error('Preserved artifact backup identity changed before cleanup.');
|
|
1634
|
+
}
|
|
1635
|
+
const backupIdentity=await anchoredSinglePathContentIdentity(
|
|
1636
|
+
transaction.backupPath,
|
|
1637
|
+
backupHandle,
|
|
1638
|
+
transaction.backupIdentity,
|
|
1639
|
+
transaction.backupContentSha256,
|
|
1640
|
+
'Preserved artifact backup'
|
|
1641
|
+
);
|
|
1642
|
+
if(!identitiesEqual(backupIdentity,transaction.backupIdentity)){
|
|
1643
|
+
throw new Error('Preserved artifact backup identity changed before cleanup.');
|
|
1644
|
+
}
|
|
1645
|
+
await rm(transaction.backupPath);
|
|
1646
|
+
return null;
|
|
1647
|
+
}catch(error){
|
|
1648
|
+
return Object.freeze({
|
|
1649
|
+
scope:'artifact-backup',
|
|
1650
|
+
path:transaction.backupPath,
|
|
1651
|
+
message:String(error?.message??error),
|
|
1652
|
+
recovery:'The verified output is committed; inspect and remove this preserved prior artifact.'
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
async function resolveOutputTarget(requested,releaseRoot){
|
|
1658
|
+
const requestedParent=path.dirname(requested);
|
|
1659
|
+
const missing=[];
|
|
1660
|
+
let existing=requestedParent;
|
|
1661
|
+
for(;;){
|
|
1662
|
+
try{
|
|
1663
|
+
const info=await lstat(existing);
|
|
1664
|
+
if(info.isSymbolicLink()||!info.isDirectory()){
|
|
1665
|
+
fail('Bundle output parent must resolve through regular directories.',ERROR_CODES.policyDenied);
|
|
1666
|
+
}
|
|
1667
|
+
break;
|
|
1668
|
+
}catch(error){
|
|
1669
|
+
if(error?.code!=='ENOENT')throw error;
|
|
1670
|
+
const parent=path.dirname(existing);
|
|
1671
|
+
if(parent===existing)throw error;
|
|
1672
|
+
missing.unshift(path.basename(existing));
|
|
1673
|
+
existing=parent;
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
const canonicalExisting=await realpath(existing);
|
|
1677
|
+
const candidateParent=path.join(canonicalExisting,...missing);
|
|
1678
|
+
if(isInside(releaseRoot,candidateParent)){
|
|
1679
|
+
fail('Bundle output cannot be inside the authenticated release root.',ERROR_CODES.policyDenied);
|
|
1680
|
+
}
|
|
1681
|
+
await mkdir(candidateParent,{recursive:true});
|
|
1682
|
+
const canonicalParent=await realpath(candidateParent);
|
|
1683
|
+
if(isInside(releaseRoot,canonicalParent)){
|
|
1684
|
+
fail('Bundle output parent resolved inside the authenticated release root.',ERROR_CODES.policyDenied);
|
|
1685
|
+
}
|
|
1686
|
+
return path.join(canonicalParent,path.basename(requested));
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
function parseCanonicalJson(bytes,label){
|
|
1690
|
+
let text;
|
|
1691
|
+
try{
|
|
1692
|
+
text=textDecoder.decode(bytes);
|
|
1693
|
+
}catch(error){
|
|
1694
|
+
fail(`${label} is not UTF-8 JSON.`,ERROR_CODES.integrityFailed,{cause:error.message});
|
|
1695
|
+
}
|
|
1696
|
+
let value;
|
|
1697
|
+
try{
|
|
1698
|
+
value=JSON.parse(text);
|
|
1699
|
+
}catch(error){
|
|
1700
|
+
fail(`${label} is not valid JSON.`,ERROR_CODES.integrityFailed,{cause:error.message});
|
|
1701
|
+
}
|
|
1702
|
+
if(!bytes.equals(canonicalJsonBytes(value))){
|
|
1703
|
+
fail(`${label} is not canonical two-space JSON with one trailing LF.`);
|
|
1704
|
+
}
|
|
1705
|
+
return value;
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
class StrictTarParser{
|
|
1709
|
+
constructor(compressedBytes){
|
|
1710
|
+
this.compressedBytes=compressedBytes;
|
|
1711
|
+
this.expansionLimit=Math.min(
|
|
1712
|
+
APP_BUNDLE_LIMITS.maxExpandedBytes,
|
|
1713
|
+
compressedBytes*APP_BUNDLE_LIMITS.maxExpansionRatio
|
|
1714
|
+
+APP_BUNDLE_LIMITS.expansionSlackBytes
|
|
1715
|
+
);
|
|
1716
|
+
this.totalExpanded=0;
|
|
1717
|
+
this.header=Buffer.alloc(TAR_BLOCK_BYTES);
|
|
1718
|
+
this.headerBytes=0;
|
|
1719
|
+
this.state='header';
|
|
1720
|
+
this.zeroBlocks=0;
|
|
1721
|
+
this.entries=[];
|
|
1722
|
+
this.pathTopology={kinds:new Map(),spellings:new Map()};
|
|
1723
|
+
this.current=null;
|
|
1724
|
+
this.remaining=0;
|
|
1725
|
+
this.padding=0;
|
|
1726
|
+
this.ended=false;
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
consume(chunk){
|
|
1730
|
+
this.totalExpanded+=chunk.length;
|
|
1731
|
+
if(this.totalExpanded>this.expansionLimit){
|
|
1732
|
+
fail('Bundle expansion exceeds its absolute or ratio ceiling.',ERROR_CODES.policyDenied);
|
|
1733
|
+
}
|
|
1734
|
+
let offset=0;
|
|
1735
|
+
while(offset<chunk.length){
|
|
1736
|
+
if(this.ended)fail('USTAR archive contains bytes after its two terminal zero blocks.');
|
|
1737
|
+
if(this.state==='header'){
|
|
1738
|
+
const count=Math.min(TAR_BLOCK_BYTES-this.headerBytes,chunk.length-offset);
|
|
1739
|
+
chunk.copy(this.header,this.headerBytes,offset,offset+count);
|
|
1740
|
+
this.headerBytes+=count;
|
|
1741
|
+
offset+=count;
|
|
1742
|
+
if(this.headerBytes===TAR_BLOCK_BYTES)this.finishHeader();
|
|
1743
|
+
continue;
|
|
1744
|
+
}
|
|
1745
|
+
if(this.state==='data'){
|
|
1746
|
+
const count=Math.min(this.remaining,chunk.length-offset);
|
|
1747
|
+
const piece=chunk.subarray(offset,offset+count);
|
|
1748
|
+
this.current.digest.update(piece);
|
|
1749
|
+
if(this.current.chunks){
|
|
1750
|
+
this.current.chunks.push(Buffer.from(piece));
|
|
1751
|
+
this.current.controlBytes+=piece.length;
|
|
1752
|
+
if(this.current.controlBytes>APP_BUNDLE_LIMITS.maxControlBytes){
|
|
1753
|
+
fail(`${this.current.path} exceeds the control-document limit.`,ERROR_CODES.policyDenied);
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
this.remaining-=count;
|
|
1757
|
+
offset+=count;
|
|
1758
|
+
if(this.remaining===0)this.finishEntry();
|
|
1759
|
+
continue;
|
|
1760
|
+
}
|
|
1761
|
+
if(this.state==='padding'){
|
|
1762
|
+
const count=Math.min(this.padding,chunk.length-offset);
|
|
1763
|
+
if(chunk.subarray(offset,offset+count).some(byte=>byte!==0)){
|
|
1764
|
+
fail(`USTAR padding is nonzero after ${this.current?.path??'an entry'}.`);
|
|
1765
|
+
}
|
|
1766
|
+
this.padding-=count;
|
|
1767
|
+
offset+=count;
|
|
1768
|
+
if(this.padding===0){
|
|
1769
|
+
this.current=null;
|
|
1770
|
+
this.state='header';
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
finishHeader(){
|
|
1777
|
+
const header=Buffer.from(this.header);
|
|
1778
|
+
this.header.fill(0);
|
|
1779
|
+
this.headerBytes=0;
|
|
1780
|
+
if(header.every(byte=>byte===0)){
|
|
1781
|
+
this.zeroBlocks+=1;
|
|
1782
|
+
if(this.zeroBlocks===2){
|
|
1783
|
+
this.ended=true;
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
this.state='header';
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1789
|
+
if(this.zeroBlocks!==0)fail('USTAR archive resumed after its first terminal zero block.');
|
|
1790
|
+
if(this.entries.length>=APP_BUNDLE_LIMITS.maxEntries){
|
|
1791
|
+
fail('USTAR archive exceeds its entry-count limit.',ERROR_CODES.policyDenied);
|
|
1792
|
+
}
|
|
1793
|
+
const parsed=parseCanonicalUstarHeader(header);
|
|
1794
|
+
const expectedControl=[
|
|
1795
|
+
APP_BUNDLE_MANIFEST_NAME,
|
|
1796
|
+
APP_BUNDLE_DESCRIPTOR_NAME,
|
|
1797
|
+
APP_BUNDLE_RELEASE_PATH
|
|
1798
|
+
][this.entries.length];
|
|
1799
|
+
if(expectedControl&&parsed.path!==expectedControl){
|
|
1800
|
+
fail(`USTAR entry ${this.entries.length+1} must be ${expectedControl}.`);
|
|
1801
|
+
}
|
|
1802
|
+
if(this.entries.length>=3&&!parsed.path.startsWith('payload/')){
|
|
1803
|
+
fail('Every non-control USTAR entry must be beneath payload/.');
|
|
1804
|
+
}
|
|
1805
|
+
registerPortablePathTopology(this.pathTopology,parsed.path,'USTAR archive topology');
|
|
1806
|
+
this.current={
|
|
1807
|
+
path:parsed.path,
|
|
1808
|
+
size:parsed.size,
|
|
1809
|
+
digest:createHash('sha256'),
|
|
1810
|
+
chunks:CONTROL_PATHS.has(parsed.path)?[]:null,
|
|
1811
|
+
controlBytes:0
|
|
1812
|
+
};
|
|
1813
|
+
this.remaining=parsed.size;
|
|
1814
|
+
this.padding=(TAR_BLOCK_BYTES-(parsed.size%TAR_BLOCK_BYTES))%TAR_BLOCK_BYTES;
|
|
1815
|
+
this.state='data';
|
|
1816
|
+
if(this.remaining===0)this.finishEntry();
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
finishEntry(){
|
|
1820
|
+
const entry=Object.freeze({
|
|
1821
|
+
path:this.current.path,
|
|
1822
|
+
bytes:this.current.size,
|
|
1823
|
+
sha256:this.current.digest.digest('hex'),
|
|
1824
|
+
...(this.current.chunks?{buffer:Buffer.concat(this.current.chunks)}:{})
|
|
1825
|
+
});
|
|
1826
|
+
this.entries.push(entry);
|
|
1827
|
+
if(this.padding===0){
|
|
1828
|
+
this.current=null;
|
|
1829
|
+
this.state='header';
|
|
1830
|
+
}else{
|
|
1831
|
+
this.state='padding';
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
finish(){
|
|
1836
|
+
if(!this.ended||this.zeroBlocks!==2||this.headerBytes!==0
|
|
1837
|
+
||this.state!=='header'||this.current!==null){
|
|
1838
|
+
fail('USTAR archive ended before its exact two-block terminator.');
|
|
1839
|
+
}
|
|
1840
|
+
return Object.freeze({
|
|
1841
|
+
entries:Object.freeze(this.entries),
|
|
1842
|
+
expandedBytes:this.totalExpanded
|
|
1843
|
+
});
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
class DeterministicGzipDigest{
|
|
1848
|
+
constructor(){
|
|
1849
|
+
this.digest=createHash('sha256');
|
|
1850
|
+
this.digest.update(GZIP_HEADER);
|
|
1851
|
+
this.compressedBytes=GZIP_HEADER.length;
|
|
1852
|
+
this.crc=0xffffffff;
|
|
1853
|
+
this.expandedBytes=0;
|
|
1854
|
+
this.deflate=createDeflateRaw({level:9});
|
|
1855
|
+
this.deflate.on('data',chunk=>{
|
|
1856
|
+
this.digest.update(chunk);
|
|
1857
|
+
this.compressedBytes+=chunk.length;
|
|
1858
|
+
});
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
async write(chunk){
|
|
1862
|
+
this.crc=updateCrc32(this.crc,chunk);
|
|
1863
|
+
this.expandedBytes=(this.expandedBytes+chunk.length)>>>0;
|
|
1864
|
+
if(!this.deflate.write(chunk))await once(this.deflate,'drain');
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
async finish(){
|
|
1868
|
+
const completed=new Promise((resolve,reject)=>{
|
|
1869
|
+
this.deflate.once('end',resolve);
|
|
1870
|
+
this.deflate.once('error',reject);
|
|
1871
|
+
});
|
|
1872
|
+
this.deflate.end();
|
|
1873
|
+
await completed;
|
|
1874
|
+
const trailer=Buffer.alloc(8);
|
|
1875
|
+
trailer.writeUInt32LE((this.crc^0xffffffff)>>>0,0);
|
|
1876
|
+
trailer.writeUInt32LE(this.expandedBytes>>>0,4);
|
|
1877
|
+
this.digest.update(trailer);
|
|
1878
|
+
this.compressedBytes+=trailer.length;
|
|
1879
|
+
return Object.freeze({
|
|
1880
|
+
sha256:this.digest.digest('hex'),
|
|
1881
|
+
compressedBytes:this.compressedBytes
|
|
1882
|
+
});
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
destroy(){
|
|
1886
|
+
this.deflate.destroy();
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
function validateFileRecord(file,label){
|
|
1891
|
+
assertExactKeys(file,['path','bytes','sha256'],label);
|
|
1892
|
+
validateAppBundlePath(file.path,`${label}.path`);
|
|
1893
|
+
assertInteger(file.bytes,{
|
|
1894
|
+
minimum:0,
|
|
1895
|
+
maximum:APP_BUNDLE_LIMITS.maxEntryBytes,
|
|
1896
|
+
label:`${label}.bytes`
|
|
1897
|
+
});
|
|
1898
|
+
assertSha256(file.sha256,`${label}.sha256`);
|
|
1899
|
+
return Object.freeze({path:file.path,bytes:file.bytes,sha256:file.sha256});
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
function validateBundleManifest(value){
|
|
1903
|
+
assertExactKeys(value,[
|
|
1904
|
+
'schemaVersion','kind','format','sdk','app','descriptor','release','payload'
|
|
1905
|
+
],APP_BUNDLE_MANIFEST_NAME);
|
|
1906
|
+
if(value.schemaVersion!==APP_BUNDLE_SCHEMA_VERSION||value.kind!==APP_BUNDLE_KIND
|
|
1907
|
+
||value.format!==APP_BUNDLE_FORMAT){
|
|
1908
|
+
fail('Bundle manifest protocol discriminator is unsupported.');
|
|
1909
|
+
}
|
|
1910
|
+
assertExactKeys(value.sdk,['name','version'],'bundle.sdk');
|
|
1911
|
+
if(value.sdk.name!==SDK_NAME)fail(`bundle.sdk.name must be ${SDK_NAME}.`);
|
|
1912
|
+
parseSemver(value.sdk.version);
|
|
1913
|
+
if(!APP_BUNDLE_SUPPORTED_SDK_VERSIONS.includes(value.sdk.version)){
|
|
1914
|
+
fail(
|
|
1915
|
+
`bundle.sdk.version ${value.sdk.version} is structurally known but not compatible with this Arcane SDK generation.`,
|
|
1916
|
+
ERROR_CODES.policyDenied
|
|
1917
|
+
);
|
|
1918
|
+
}
|
|
1919
|
+
assertExactKeys(value.app,['id','version'],'bundle.app');
|
|
1920
|
+
if(typeof value.app.id!=='string'||!APP_ID_PATTERN.test(value.app.id))fail('bundle.app.id is invalid.');
|
|
1921
|
+
parseSemver(value.app.version);
|
|
1922
|
+
assertExactKeys(value.descriptor,[
|
|
1923
|
+
'path','schemaVersion','canonicalSha256','packageSha256','fileSha256','bytes'
|
|
1924
|
+
],'bundle.descriptor');
|
|
1925
|
+
if(value.descriptor.path!==APP_BUNDLE_DESCRIPTOR_NAME||value.descriptor.schemaVersion!==2){
|
|
1926
|
+
fail('bundle.descriptor must identify the authored schema-2 descriptor.');
|
|
1927
|
+
}
|
|
1928
|
+
for(const field of ['canonicalSha256','packageSha256','fileSha256']){
|
|
1929
|
+
assertSha256(value.descriptor[field],`bundle.descriptor.${field}`);
|
|
1930
|
+
}
|
|
1931
|
+
assertInteger(value.descriptor.bytes,{
|
|
1932
|
+
minimum:1,
|
|
1933
|
+
maximum:APP_BUNDLE_LIMITS.maxControlBytes,
|
|
1934
|
+
label:'bundle.descriptor.bytes'
|
|
1935
|
+
});
|
|
1936
|
+
assertExactKeys(value.release,[
|
|
1937
|
+
'path','schemaVersion','builder','policySha256','manifestSha256',
|
|
1938
|
+
'contentSha256','fileCount','totalBytes'
|
|
1939
|
+
],'bundle.release');
|
|
1940
|
+
if(value.release.path!==APP_BUNDLE_RELEASE_PATH||value.release.schemaVersion!==1
|
|
1941
|
+
||value.release.builder!==PACKAGER_VERSION){
|
|
1942
|
+
fail('bundle.release identifies an unsupported app release contract.');
|
|
1943
|
+
}
|
|
1944
|
+
for(const field of ['policySha256','manifestSha256','contentSha256']){
|
|
1945
|
+
assertSha256(value.release[field],`bundle.release.${field}`);
|
|
1946
|
+
}
|
|
1947
|
+
assertInteger(value.release.fileCount,{
|
|
1948
|
+
minimum:1,
|
|
1949
|
+
maximum:APP_BUNDLE_LIMITS.maxPayloadFiles,
|
|
1950
|
+
label:'bundle.release.fileCount'
|
|
1951
|
+
});
|
|
1952
|
+
assertInteger(value.release.totalBytes,{
|
|
1953
|
+
minimum:1,
|
|
1954
|
+
maximum:APP_BUNDLE_LIMITS.maxExpandedBytes,
|
|
1955
|
+
label:'bundle.release.totalBytes'
|
|
1956
|
+
});
|
|
1957
|
+
assertExactKeys(value.payload,['root','fileCount','totalBytes','files'],'bundle.payload');
|
|
1958
|
+
if(value.payload.root!=='payload'||!Array.isArray(value.payload.files)){
|
|
1959
|
+
fail('bundle.payload must contain one exact payload inventory.');
|
|
1960
|
+
}
|
|
1961
|
+
if(value.payload.files.length<1||value.payload.files.length>APP_BUNDLE_LIMITS.maxPayloadFiles){
|
|
1962
|
+
fail('bundle.payload.files exceeds its cardinality contract.');
|
|
1963
|
+
}
|
|
1964
|
+
const keys=new Set();
|
|
1965
|
+
let totalBytes=0;
|
|
1966
|
+
let previous=null;
|
|
1967
|
+
const files=value.payload.files.map((file,index)=>{
|
|
1968
|
+
const record=validateFileRecord(file,`bundle.payload.files[${index}]`);
|
|
1969
|
+
if(pathKey(record.path)===pathKey(RELEASE_MANIFEST_NAME)){
|
|
1970
|
+
fail(`bundle.payload.files must not contain ${RELEASE_MANIFEST_NAME}.`);
|
|
1971
|
+
}
|
|
1972
|
+
const key=pathKey(record.path);
|
|
1973
|
+
if(keys.has(key))fail(`Bundle payload has a duplicate or case-colliding path: ${record.path}.`);
|
|
1974
|
+
keys.add(key);
|
|
1975
|
+
if(previous!==null&&compareText(previous,record.path)>=0){
|
|
1976
|
+
fail('Bundle payload inventory must use strict canonical path order.');
|
|
1977
|
+
}
|
|
1978
|
+
previous=record.path;
|
|
1979
|
+
totalBytes+=record.bytes;
|
|
1980
|
+
if(!Number.isSafeInteger(totalBytes)||totalBytes>APP_BUNDLE_LIMITS.maxExpandedBytes){
|
|
1981
|
+
fail('Bundle payload exceeds its expanded byte ceiling.',ERROR_CODES.policyDenied);
|
|
1982
|
+
}
|
|
1983
|
+
return record;
|
|
1984
|
+
});
|
|
1985
|
+
validatePortablePathTopology([
|
|
1986
|
+
APP_BUNDLE_MANIFEST_NAME,
|
|
1987
|
+
APP_BUNDLE_DESCRIPTOR_NAME,
|
|
1988
|
+
APP_BUNDLE_RELEASE_PATH,
|
|
1989
|
+
...files.map(file=>`payload/${file.path}`)
|
|
1990
|
+
],'Bundle archive topology');
|
|
1991
|
+
if(value.payload.fileCount!==files.length||value.payload.totalBytes!==totalBytes
|
|
1992
|
+
||value.release.fileCount!==files.length||value.release.totalBytes!==totalBytes){
|
|
1993
|
+
fail('Bundle payload and release totals do not match their inventory.');
|
|
1994
|
+
}
|
|
1995
|
+
return {manifest:value,files:Object.freeze(files)};
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
function validateEmbeddedRelease(value,descriptor,files,bundle){
|
|
1999
|
+
assertExactKeys(value,[
|
|
2000
|
+
'schemaVersion','builder','app','policySha256','fileCount','totalBytes','contentSha256','files'
|
|
2001
|
+
],RELEASE_MANIFEST_NAME);
|
|
2002
|
+
if(value.schemaVersion!==1||value.builder!==PACKAGER_VERSION){
|
|
2003
|
+
fail('Embedded release manifest protocol is unsupported.');
|
|
2004
|
+
}
|
|
2005
|
+
assertExactKeys(value.app,[
|
|
2006
|
+
'id','displayName','version','entry','start','security','localAIModelPolicy'
|
|
2007
|
+
],'embedded release app');
|
|
2008
|
+
const expectedApp=expectedReleaseApp(descriptor);
|
|
2009
|
+
if(JSON.stringify(value.app)!==JSON.stringify(expectedApp)){
|
|
2010
|
+
fail('Embedded release app does not match the authored descriptor.');
|
|
2011
|
+
}
|
|
2012
|
+
const releaseFiles=Array.isArray(value.files)
|
|
2013
|
+
?value.files.map((file,index)=>validateFileRecord(file,`release.files[${index}]`))
|
|
2014
|
+
:null;
|
|
2015
|
+
if(!releaseFiles||JSON.stringify(releaseFiles)!==JSON.stringify(files)){
|
|
2016
|
+
fail('Embedded release inventory does not match the bundle payload inventory.');
|
|
2017
|
+
}
|
|
2018
|
+
const totalBytes=files.reduce((total,file)=>total+file.bytes,0);
|
|
2019
|
+
const contentSha256=sha256(JSON.stringify(files));
|
|
2020
|
+
assertSha256(value.policySha256,'release.policySha256');
|
|
2021
|
+
if(value.fileCount!==files.length||value.totalBytes!==totalBytes
|
|
2022
|
+
||value.contentSha256!==contentSha256){
|
|
2023
|
+
fail('Embedded release inventory totals or content digest are invalid.');
|
|
2024
|
+
}
|
|
2025
|
+
if(bundle.release.builder!==value.builder
|
|
2026
|
+
||bundle.release.policySha256!==value.policySha256
|
|
2027
|
+
||bundle.release.contentSha256!==value.contentSha256
|
|
2028
|
+
||bundle.release.fileCount!==value.fileCount
|
|
2029
|
+
||bundle.release.totalBytes!==value.totalBytes){
|
|
2030
|
+
fail('Bundle release binding does not match the embedded release manifest.');
|
|
2031
|
+
}
|
|
2032
|
+
return value;
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
function validateParsedBundle(parsed,{
|
|
2036
|
+
bundleSha256,
|
|
2037
|
+
compressedBytes,
|
|
2038
|
+
bundlePath,
|
|
2039
|
+
artifactIdentity
|
|
2040
|
+
}){
|
|
2041
|
+
const entries=parsed.entries;
|
|
2042
|
+
if(entries.length<4)fail('Bundle must contain three control documents and at least one payload file.');
|
|
2043
|
+
const [bundleEntry,descriptorEntry,releaseEntry]=entries;
|
|
2044
|
+
const {manifest,files}=validateBundleManifest(
|
|
2045
|
+
parseCanonicalJson(bundleEntry.buffer,APP_BUNDLE_MANIFEST_NAME)
|
|
2046
|
+
);
|
|
2047
|
+
const expectedPaths=[
|
|
2048
|
+
APP_BUNDLE_MANIFEST_NAME,
|
|
2049
|
+
APP_BUNDLE_DESCRIPTOR_NAME,
|
|
2050
|
+
APP_BUNDLE_RELEASE_PATH,
|
|
2051
|
+
...files.map(file=>`payload/${file.path}`)
|
|
2052
|
+
];
|
|
2053
|
+
if(JSON.stringify(entries.map(entry=>entry.path))!==JSON.stringify(expectedPaths)){
|
|
2054
|
+
fail('Bundle USTAR topology or entry order does not match the manifest.');
|
|
2055
|
+
}
|
|
2056
|
+
const descriptorDocument=parseCanonicalJson(descriptorEntry.buffer,APP_BUNDLE_DESCRIPTOR_NAME);
|
|
2057
|
+
const descriptor=validateAppDescriptor(descriptorDocument,{appId:manifest.app.id});
|
|
2058
|
+
if(!descriptorEntry.buffer.equals(canonicalJsonBytes(descriptor))
|
|
2059
|
+
||descriptor.version!==manifest.app.version
|
|
2060
|
+
||descriptor.schemaVersion!==manifest.descriptor.schemaVersion
|
|
2061
|
+
||descriptorEntry.bytes!==manifest.descriptor.bytes
|
|
2062
|
+
||descriptorEntry.sha256!==manifest.descriptor.fileSha256
|
|
2063
|
+
||appDescriptorSha256(descriptor)!==manifest.descriptor.canonicalSha256
|
|
2064
|
+
||sha256(JSON.stringify(projectPackageManifest(descriptor)))!==manifest.descriptor.packageSha256){
|
|
2065
|
+
fail('Authored descriptor binding does not match the bundle manifest.');
|
|
2066
|
+
}
|
|
2067
|
+
const releaseDocument=parseCanonicalJson(releaseEntry.buffer,RELEASE_MANIFEST_NAME);
|
|
2068
|
+
validateEmbeddedRelease(releaseDocument,descriptor,files,manifest);
|
|
2069
|
+
if(releaseEntry.sha256!==manifest.release.manifestSha256){
|
|
2070
|
+
fail('Embedded release manifest digest does not match the bundle manifest.');
|
|
2071
|
+
}
|
|
2072
|
+
for(let index=0;index<files.length;index+=1){
|
|
2073
|
+
const entry=entries[index+3];
|
|
2074
|
+
const file=files[index];
|
|
2075
|
+
if(entry.bytes!==file.bytes||entry.sha256!==file.sha256){
|
|
2076
|
+
fail(`Payload entry does not match its release identity: ${file.path}.`);
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
const consistency=Object.freeze({
|
|
2080
|
+
artifact:Object.freeze({sha256:bundleSha256,bytes:compressedBytes}),
|
|
2081
|
+
descriptor:Object.freeze({
|
|
2082
|
+
canonicalSha256:manifest.descriptor.canonicalSha256,
|
|
2083
|
+
fileSha256:manifest.descriptor.fileSha256,
|
|
2084
|
+
packageSha256:manifest.descriptor.packageSha256,
|
|
2085
|
+
bytes:manifest.descriptor.bytes
|
|
2086
|
+
}),
|
|
2087
|
+
release:Object.freeze({
|
|
2088
|
+
manifestSha256:manifest.release.manifestSha256,
|
|
2089
|
+
policySha256:manifest.release.policySha256,
|
|
2090
|
+
contentSha256:manifest.release.contentSha256,
|
|
2091
|
+
fileCount:manifest.release.fileCount,
|
|
2092
|
+
totalBytes:manifest.release.totalBytes
|
|
2093
|
+
})
|
|
2094
|
+
});
|
|
2095
|
+
return Object.freeze({
|
|
2096
|
+
schemaVersion:APP_BUNDLE_SCHEMA_VERSION,
|
|
2097
|
+
kind:'arcane-app-release-bundle-verification',
|
|
2098
|
+
verified:true,
|
|
2099
|
+
arcaneCompatible:true,
|
|
2100
|
+
bundlePath,
|
|
2101
|
+
bundleSha256,
|
|
2102
|
+
compressedBytes,
|
|
2103
|
+
artifactIdentity,
|
|
2104
|
+
expandedBytes:parsed.expandedBytes,
|
|
2105
|
+
entryCount:entries.length,
|
|
2106
|
+
sdk:Object.freeze({...manifest.sdk}),
|
|
2107
|
+
app:Object.freeze({...manifest.app}),
|
|
2108
|
+
descriptorSha256:manifest.descriptor.canonicalSha256,
|
|
2109
|
+
descriptorFileSha256:manifest.descriptor.fileSha256,
|
|
2110
|
+
descriptorBytes:manifest.descriptor.bytes,
|
|
2111
|
+
packageSha256:manifest.descriptor.packageSha256,
|
|
2112
|
+
releaseManifestSha256:manifest.release.manifestSha256,
|
|
2113
|
+
releasePolicySha256:manifest.release.policySha256,
|
|
2114
|
+
releaseContentSha256:manifest.release.contentSha256,
|
|
2115
|
+
fileCount:manifest.release.fileCount,
|
|
2116
|
+
totalBytes:manifest.release.totalBytes,
|
|
2117
|
+
consistency
|
|
2118
|
+
});
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
export async function verifyAppReleaseBundle({bundlePath,signal,onEvent}={}){
|
|
2122
|
+
throwIfAborted(signal);
|
|
2123
|
+
if(typeof bundlePath!=='string'||!bundlePath.trim()){
|
|
2124
|
+
fail('bundlePath is required to verify an app release bundle.',ERROR_CODES.usage);
|
|
2125
|
+
}
|
|
2126
|
+
const requested=path.resolve(bundlePath);
|
|
2127
|
+
await emit(onEvent,{type:'bundle.verify.started',bundlePath:requested});
|
|
2128
|
+
const opened=await openStableFile(requested,'app release bundle',{
|
|
2129
|
+
maximum:APP_BUNDLE_LIMITS.maxCompressedBytes
|
|
2130
|
+
});
|
|
2131
|
+
let recompressed;
|
|
2132
|
+
try{
|
|
2133
|
+
const canonicalRequested=await realpath(requested);
|
|
2134
|
+
const canonicalInfo=await lstat(canonicalRequested,{bigint:true});
|
|
2135
|
+
if(canonicalInfo.isSymbolicLink()||!canonicalInfo.isFile()||canonicalInfo.nlink!==1n
|
|
2136
|
+
||!identityMatches(canonicalInfo,opened.identity)){
|
|
2137
|
+
fail('App release bundle canonical path did not bind to its opened identity.');
|
|
2138
|
+
}
|
|
2139
|
+
await emit(onEvent,{
|
|
2140
|
+
type:'bundle.verify.opened',
|
|
2141
|
+
bundlePath:canonicalRequested,
|
|
2142
|
+
compressedBytes:opened.identity.bytes
|
|
2143
|
+
});
|
|
2144
|
+
if(opened.identity.bytes<GZIP_HEADER.length+8){
|
|
2145
|
+
fail('App release bundle is too short to be canonical gzip.');
|
|
2146
|
+
}
|
|
2147
|
+
const header=Buffer.alloc(GZIP_HEADER.length);
|
|
2148
|
+
const first=await opened.handle.read(header,0,header.length,0);
|
|
2149
|
+
if(first.bytesRead!==header.length||!header.equals(GZIP_HEADER)){
|
|
2150
|
+
fail('Bundle gzip header is not the deterministic Arcane header.');
|
|
2151
|
+
}
|
|
2152
|
+
const parser=new StrictTarParser(opened.identity.bytes);
|
|
2153
|
+
recompressed=new DeterministicGzipDigest();
|
|
2154
|
+
const actualDigest=createHash('sha256');
|
|
2155
|
+
let actualBytes=0;
|
|
2156
|
+
const actual=new Transform({
|
|
2157
|
+
transform(chunk,_encoding,callback){
|
|
2158
|
+
try{
|
|
2159
|
+
actualBytes+=chunk.length;
|
|
2160
|
+
if(actualBytes>opened.identity.bytes
|
|
2161
|
+
||actualBytes>APP_BUNDLE_LIMITS.maxCompressedBytes){
|
|
2162
|
+
fail('Bundle compressed stream exceeded its recorded byte ceiling.',ERROR_CODES.policyDenied);
|
|
2163
|
+
}
|
|
2164
|
+
actualDigest.update(chunk);
|
|
2165
|
+
callback(null,chunk);
|
|
2166
|
+
}catch(error){
|
|
2167
|
+
callback(error);
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
});
|
|
2171
|
+
const gunzip=createGunzip();
|
|
2172
|
+
const consume=new Writable({
|
|
2173
|
+
write(chunk,_encoding,callback){
|
|
2174
|
+
try{
|
|
2175
|
+
throwIfAborted(signal);
|
|
2176
|
+
parser.consume(chunk);
|
|
2177
|
+
recompressed.write(chunk).then(()=>callback(),callback);
|
|
2178
|
+
}catch(error){
|
|
2179
|
+
callback(error);
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
});
|
|
2183
|
+
await pipeline(
|
|
2184
|
+
opened.handle.createReadStream({
|
|
2185
|
+
autoClose:false,
|
|
2186
|
+
start:0,
|
|
2187
|
+
end:opened.identity.bytes-1
|
|
2188
|
+
}),
|
|
2189
|
+
actual,
|
|
2190
|
+
gunzip,
|
|
2191
|
+
consume,
|
|
2192
|
+
{signal}
|
|
2193
|
+
);
|
|
2194
|
+
const parsed=parser.finish();
|
|
2195
|
+
const deterministic=await recompressed.finish();
|
|
2196
|
+
const bundleSha256=actualDigest.digest('hex');
|
|
2197
|
+
const probe=Buffer.alloc(1);
|
|
2198
|
+
const extra=await opened.handle.read(probe,0,1,opened.identity.bytes);
|
|
2199
|
+
if(extra.bytesRead!==0)fail('App release bundle grew while it was verified.');
|
|
2200
|
+
if(actualBytes!==opened.identity.bytes
|
|
2201
|
+
||deterministic.compressedBytes!==actualBytes
|
|
2202
|
+
||deterministic.sha256!==bundleSha256){
|
|
2203
|
+
fail('Bundle gzip member is not the exact deterministic Arcane encoding.');
|
|
2204
|
+
}
|
|
2205
|
+
const canonicalCurrent=await realpath(requested);
|
|
2206
|
+
const after=await opened.handle.stat({bigint:true});
|
|
2207
|
+
const current=await lstat(requested,{bigint:true});
|
|
2208
|
+
const canonicalAfter=await lstat(canonicalRequested,{bigint:true});
|
|
2209
|
+
if(after.nlink!==1n||current.nlink!==1n||canonicalAfter.nlink!==1n
|
|
2210
|
+
||!identityMatches(after,opened.identity)||current.isSymbolicLink()
|
|
2211
|
+
||canonicalAfter.isSymbolicLink()||!current.isFile()||!canonicalAfter.isFile()
|
|
2212
|
+
||!identityMatches(current,opened.identity)
|
|
2213
|
+
||!identityMatches(canonicalAfter,opened.identity)
|
|
2214
|
+
||canonicalCurrent!==canonicalRequested){
|
|
2215
|
+
fail('App release bundle changed while it was verified.');
|
|
2216
|
+
}
|
|
2217
|
+
const receipt=validateParsedBundle(parsed,{
|
|
2218
|
+
bundleSha256,
|
|
2219
|
+
compressedBytes:actualBytes,
|
|
2220
|
+
bundlePath:canonicalRequested,
|
|
2221
|
+
artifactIdentity:opened.identity
|
|
2222
|
+
});
|
|
2223
|
+
await emit(onEvent,{
|
|
2224
|
+
type:'bundle.verify.completed',
|
|
2225
|
+
bundlePath:receipt.bundlePath,
|
|
2226
|
+
bundleSha256:receipt.bundleSha256,
|
|
2227
|
+
entryCount:receipt.entryCount
|
|
2228
|
+
});
|
|
2229
|
+
return receipt;
|
|
2230
|
+
}catch(error){
|
|
2231
|
+
recompressed?.destroy();
|
|
2232
|
+
if(error?.name==='AbortError'||signal?.aborted)throwIfAborted(signal);
|
|
2233
|
+
throw error;
|
|
2234
|
+
}finally{
|
|
2235
|
+
await opened.handle.close().catch(()=>{});
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
export async function createAppReleaseBundle({
|
|
2240
|
+
receipt,
|
|
2241
|
+
releaseRoot,
|
|
2242
|
+
outputPath,
|
|
2243
|
+
overwrite=false,
|
|
2244
|
+
signal,
|
|
2245
|
+
onEvent
|
|
2246
|
+
}={}){
|
|
2247
|
+
throwIfAborted(signal);
|
|
2248
|
+
if(typeof overwrite!=='boolean'){
|
|
2249
|
+
fail('overwrite must be a literal boolean.',ERROR_CODES.usage);
|
|
2250
|
+
}
|
|
2251
|
+
if(typeof releaseRoot!=='string'||!releaseRoot.trim()){
|
|
2252
|
+
fail('releaseRoot is required to create an app release bundle.',ERROR_CODES.usage);
|
|
2253
|
+
}
|
|
2254
|
+
if(typeof outputPath!=='string'||!outputPath.trim()){
|
|
2255
|
+
fail('outputPath is required to create an app release bundle.',ERROR_CODES.usage);
|
|
2256
|
+
}
|
|
2257
|
+
const requestedOutput=path.resolve(outputPath);
|
|
2258
|
+
validateOutputFilename(requestedOutput);
|
|
2259
|
+
const authority=await authenticateAppReleaseAuthority(receipt,{releaseRoot,signal});
|
|
2260
|
+
const canonicalReleaseRoot=await realpath(path.resolve(releaseRoot));
|
|
2261
|
+
const descriptor=validateAppDescriptor(authority.descriptor,{appId:receipt.app?.id});
|
|
2262
|
+
if(authority.source!=='authored'||descriptor.schemaVersion!==2
|
|
2263
|
+
||authority.descriptorSha256!==appDescriptorSha256(descriptor)){
|
|
2264
|
+
fail('Bundle creation requires one authenticated authored schema-2 descriptor.');
|
|
2265
|
+
}
|
|
2266
|
+
const files=validateReleaseInventory(receipt);
|
|
2267
|
+
const release=releaseDocumentFromReceipt(receipt,descriptor,files);
|
|
2268
|
+
const releaseBytes=canonicalJsonBytes(release);
|
|
2269
|
+
const sourceReleaseBytes=await readStableControlFile(
|
|
2270
|
+
path.join(canonicalReleaseRoot,RELEASE_MANIFEST_NAME),
|
|
2271
|
+
RELEASE_MANIFEST_NAME,
|
|
2272
|
+
{signal}
|
|
2273
|
+
);
|
|
2274
|
+
if(!sourceReleaseBytes.equals(releaseBytes)){
|
|
2275
|
+
fail('Authenticated release manifest bytes are not the canonical receipt projection.');
|
|
2276
|
+
}
|
|
2277
|
+
const descriptorBytes=canonicalJsonBytes(descriptor);
|
|
2278
|
+
if(descriptorBytes.length>APP_BUNDLE_LIMITS.maxControlBytes){
|
|
2279
|
+
fail('Authored app descriptor exceeds the control-document limit.',ERROR_CODES.policyDenied);
|
|
2280
|
+
}
|
|
2281
|
+
const manifest=createBundleManifest({
|
|
2282
|
+
descriptor,
|
|
2283
|
+
descriptorBytes,
|
|
2284
|
+
descriptorSha256:authority.descriptorSha256,
|
|
2285
|
+
release,
|
|
2286
|
+
releaseBytes
|
|
2287
|
+
});
|
|
2288
|
+
const manifestBytes=canonicalJsonBytes(manifest);
|
|
2289
|
+
if(manifestBytes.length>APP_BUNDLE_LIMITS.maxControlBytes){
|
|
2290
|
+
fail('Bundle manifest exceeds the control-document limit.',ERROR_CODES.policyDenied);
|
|
2291
|
+
}
|
|
2292
|
+
const entries=[
|
|
2293
|
+
{path:APP_BUNDLE_MANIFEST_NAME,bytes:manifestBytes.length,buffer:manifestBytes},
|
|
2294
|
+
{path:APP_BUNDLE_DESCRIPTOR_NAME,bytes:descriptorBytes.length,buffer:descriptorBytes},
|
|
2295
|
+
{path:APP_BUNDLE_RELEASE_PATH,bytes:releaseBytes.length,buffer:releaseBytes},
|
|
2296
|
+
...files.map(identity=>({
|
|
2297
|
+
path:validateAppBundlePath(`payload/${identity.path}`,'bundle payload archive path'),
|
|
2298
|
+
bytes:identity.bytes,
|
|
2299
|
+
identity
|
|
2300
|
+
}))
|
|
2301
|
+
];
|
|
2302
|
+
validatePortablePathTopology(entries.map(entry=>entry.path),'Bundle archive topology');
|
|
2303
|
+
if(entries.length>APP_BUNDLE_LIMITS.maxEntries){
|
|
2304
|
+
fail('Bundle exceeds the archive entry-count limit.',ERROR_CODES.policyDenied);
|
|
2305
|
+
}
|
|
2306
|
+
const output=await resolveOutputTarget(requestedOutput,canonicalReleaseRoot);
|
|
2307
|
+
const token=`${process.pid}-${Date.now()}-${randomBytes(6).toString('hex')}`;
|
|
2308
|
+
const temporary=path.join(path.dirname(output),`.${path.basename(output)}.${token}.tmp`);
|
|
2309
|
+
const releaseLock=await acquireArtifactLock(output,{onEvent});
|
|
2310
|
+
let handle;
|
|
2311
|
+
let existingOutput;
|
|
2312
|
+
let temporaryIdentity;
|
|
2313
|
+
const stagingState={
|
|
2314
|
+
identity:null,
|
|
2315
|
+
contentBytes:0,
|
|
2316
|
+
contentSha256:sha256(Buffer.alloc(0))
|
|
2317
|
+
};
|
|
2318
|
+
let committed=false;
|
|
2319
|
+
let promotion;
|
|
2320
|
+
let result;
|
|
2321
|
+
let operationError;
|
|
2322
|
+
const cleanupIssues=[];
|
|
2323
|
+
try{
|
|
2324
|
+
existingOutput=await inspectOutput(output,{overwrite,signal});
|
|
2325
|
+
await emit(onEvent,{
|
|
2326
|
+
type:'bundle.archive.started',
|
|
2327
|
+
outputPath:output,
|
|
2328
|
+
appId:descriptor.id,
|
|
2329
|
+
version:descriptor.version,
|
|
2330
|
+
entryCount:entries.length
|
|
2331
|
+
});
|
|
2332
|
+
handle=await open(temporary,'wx+',0o600);
|
|
2333
|
+
const createdTemporary=await handle.stat({bigint:true});
|
|
2334
|
+
if(!createdTemporary.isFile()||createdTemporary.nlink!==1n){
|
|
2335
|
+
fail('New bundle staging is not an owned single-link regular file.',ERROR_CODES.policyDenied);
|
|
2336
|
+
}
|
|
2337
|
+
const encoded=await writeDeterministicGzip(handle,entries,{
|
|
2338
|
+
releaseRoot:canonicalReleaseRoot,
|
|
2339
|
+
signal,
|
|
2340
|
+
onEvent,
|
|
2341
|
+
stagingState
|
|
2342
|
+
});
|
|
2343
|
+
if(stagingState.contentSha256!==encoded.sha256
|
|
2344
|
+
||stagingState.contentBytes!==encoded.bytes){
|
|
2345
|
+
fail('Archive writer did not retain its encoded content identity.');
|
|
2346
|
+
}
|
|
2347
|
+
await handle.chmod(ARCHIVE_MODE);
|
|
2348
|
+
await handle.sync();
|
|
2349
|
+
const encodedTemporary=await handle.stat({bigint:true});
|
|
2350
|
+
if(!encodedTemporary.isFile()||encodedTemporary.nlink!==1n){
|
|
2351
|
+
fail('Encoded bundle staging is not the originally created file object.',ERROR_CODES.policyDenied);
|
|
2352
|
+
}
|
|
2353
|
+
temporaryIdentity=fileIdentity(encodedTemporary);
|
|
2354
|
+
stagingState.identity=temporaryIdentity;
|
|
2355
|
+
throwIfAborted(signal);
|
|
2356
|
+
await authenticateAppReleaseAuthority(receipt,{releaseRoot:canonicalReleaseRoot,signal});
|
|
2357
|
+
const verified=await verifyAppReleaseBundle({bundlePath:temporary,signal});
|
|
2358
|
+
if(verified.bundleSha256!==encoded.sha256||verified.compressedBytes!==encoded.bytes){
|
|
2359
|
+
fail('New bundle verification did not reproduce its encoded identity.');
|
|
2360
|
+
}
|
|
2361
|
+
const temporaryInfo=await lstat(temporary,{bigint:true});
|
|
2362
|
+
if(temporaryInfo.isSymbolicLink()||!temporaryInfo.isFile()||temporaryInfo.nlink!==1n){
|
|
2363
|
+
fail('Verified bundle staging is not a regular file.',ERROR_CODES.policyDenied);
|
|
2364
|
+
}
|
|
2365
|
+
temporaryIdentity=fileIdentity(temporaryInfo);
|
|
2366
|
+
const anchoredTemporary=await handle.stat({bigint:true});
|
|
2367
|
+
if(!anchoredTemporary.isFile()||anchoredTemporary.nlink!==1n
|
|
2368
|
+
||!identityMatches(anchoredTemporary,temporaryIdentity)){
|
|
2369
|
+
fail('Verified bundle staging is not the originally created file object.',ERROR_CODES.policyDenied);
|
|
2370
|
+
}
|
|
2371
|
+
if(!identitiesEqual(temporaryIdentity,verified.artifactIdentity)){
|
|
2372
|
+
fail('Verified bundle staging changed after independent verification.');
|
|
2373
|
+
}
|
|
2374
|
+
stagingState.identity=temporaryIdentity;
|
|
2375
|
+
stagingState.contentBytes=verified.compressedBytes;
|
|
2376
|
+
stagingState.contentSha256=verified.bundleSha256;
|
|
2377
|
+
await emit(onEvent,{
|
|
2378
|
+
type:'bundle.archive.verified',
|
|
2379
|
+
bundleSha256:verified.bundleSha256,
|
|
2380
|
+
compressedBytes:verified.compressedBytes
|
|
2381
|
+
});
|
|
2382
|
+
throwIfAborted(signal);
|
|
2383
|
+
promotion=await promoteArtifact(temporary,output,{
|
|
2384
|
+
existingOutput,
|
|
2385
|
+
stagingState,
|
|
2386
|
+
anchorHandle:handle,
|
|
2387
|
+
onEvent,
|
|
2388
|
+
signal
|
|
2389
|
+
});
|
|
2390
|
+
await emit(onEvent,{
|
|
2391
|
+
type:'bundle.archive.promoted',
|
|
2392
|
+
bundlePath:output,
|
|
2393
|
+
bundleSha256:verified.bundleSha256
|
|
2394
|
+
});
|
|
2395
|
+
const promoted=await stableFileDigest(output,'promoted app release bundle',{signal});
|
|
2396
|
+
if(promoted.sha256!==verified.bundleSha256
|
|
2397
|
+
||promoted.bytes!==verified.compressedBytes){
|
|
2398
|
+
fail('Promoted bundle identity does not match the independently verified staging bytes.');
|
|
2399
|
+
}
|
|
2400
|
+
const anchoredPromoted=await handle.stat({bigint:true});
|
|
2401
|
+
if(!anchoredPromoted.isFile()||anchoredPromoted.nlink!==1n
|
|
2402
|
+
||!identityMatches(anchoredPromoted,promoted.identity)){
|
|
2403
|
+
fail('Promoted bundle digest was not read from the anchored staging object.');
|
|
2404
|
+
}
|
|
2405
|
+
throwIfAborted(signal);
|
|
2406
|
+
const artifactReceipt=Object.freeze({
|
|
2407
|
+
...verified,
|
|
2408
|
+
kind:'arcane-app-release-bundle-artifact',
|
|
2409
|
+
bundlePath:promoted.path,
|
|
2410
|
+
artifactIdentity:promoted.identity
|
|
2411
|
+
});
|
|
2412
|
+
await handle.close();
|
|
2413
|
+
handle=null;
|
|
2414
|
+
committed=true;
|
|
2415
|
+
const backupCleanup=await finalizePromotion(promotion,{
|
|
2416
|
+
backupHandle:existingOutput?.handle
|
|
2417
|
+
});
|
|
2418
|
+
if(backupCleanup)cleanupIssues.push(backupCleanup);
|
|
2419
|
+
let eventDelivery;
|
|
2420
|
+
try{
|
|
2421
|
+
await emit(onEvent,{
|
|
2422
|
+
type:'bundle.committed',
|
|
2423
|
+
phase:'publish',
|
|
2424
|
+
status:'completed',
|
|
2425
|
+
bundlePath:artifactReceipt.bundlePath,
|
|
2426
|
+
bundleSha256:artifactReceipt.bundleSha256
|
|
2427
|
+
});
|
|
2428
|
+
}catch(error){
|
|
2429
|
+
eventDelivery=Object.freeze({
|
|
2430
|
+
status:'degraded',
|
|
2431
|
+
errorCode:'ARCANE_EVENT_DELIVERY_FAILED',
|
|
2432
|
+
message:String(error?.message??error)
|
|
2433
|
+
});
|
|
2434
|
+
}
|
|
2435
|
+
result={
|
|
2436
|
+
app:descriptor.id,
|
|
2437
|
+
version:descriptor.version,
|
|
2438
|
+
outputPath:artifactReceipt.bundlePath,
|
|
2439
|
+
bundleSha256:artifactReceipt.bundleSha256,
|
|
2440
|
+
compressedBytes:artifactReceipt.compressedBytes,
|
|
2441
|
+
entryCount:artifactReceipt.entryCount,
|
|
2442
|
+
artifactReceipt,
|
|
2443
|
+
...(eventDelivery?{eventDelivery}:{})
|
|
2444
|
+
};
|
|
2445
|
+
}catch(error){
|
|
2446
|
+
operationError=error;
|
|
2447
|
+
if(promotion&&!committed){
|
|
2448
|
+
const rollbackIssues=await rollbackPromotion(promotion,{
|
|
2449
|
+
promotedHandle:handle,
|
|
2450
|
+
backupHandle:existingOutput?.handle
|
|
2451
|
+
});
|
|
2452
|
+
if(rollbackIssues.length){
|
|
2453
|
+
appendErrorWarning(error,`Rollback warning: ${rollbackIssues.join('; ')}`);
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
}finally{
|
|
2457
|
+
let stagingCleanupHandled=false;
|
|
2458
|
+
let retryStagingIdentity=null;
|
|
2459
|
+
if(!committed&&handle){
|
|
2460
|
+
const stagingCleanup=await cleanupAnchoredTemporary(
|
|
2461
|
+
temporary,
|
|
2462
|
+
handle,
|
|
2463
|
+
stagingState?.identity??temporaryIdentity,
|
|
2464
|
+
stagingState.contentSha256,
|
|
2465
|
+
stagingState.contentBytes
|
|
2466
|
+
);
|
|
2467
|
+
if(stagingCleanup.issue)cleanupIssues.push(stagingCleanup.issue);
|
|
2468
|
+
retryStagingIdentity=stagingCleanup.retryIdentity;
|
|
2469
|
+
stagingCleanupHandled=true;
|
|
2470
|
+
}
|
|
2471
|
+
if(handle){
|
|
2472
|
+
try{
|
|
2473
|
+
await handle.close();
|
|
2474
|
+
}catch(error){
|
|
2475
|
+
cleanupIssues.push(changedStagingCleanupIssue(
|
|
2476
|
+
temporary,
|
|
2477
|
+
`Staging creation handle close warning for ${temporary}: ${String(error?.message??error)}`
|
|
2478
|
+
));
|
|
2479
|
+
}
|
|
2480
|
+
handle=null;
|
|
2481
|
+
}
|
|
2482
|
+
if(!committed&&(!stagingCleanupHandled||retryStagingIdentity)){
|
|
2483
|
+
const stagingCleanup=await cleanupOwnedTemporary(
|
|
2484
|
+
temporary,
|
|
2485
|
+
retryStagingIdentity??stagingState?.identity??temporaryIdentity,
|
|
2486
|
+
stagingState.contentSha256,
|
|
2487
|
+
stagingState.contentBytes
|
|
2488
|
+
);
|
|
2489
|
+
if(stagingCleanup)cleanupIssues.push(stagingCleanup);
|
|
2490
|
+
}
|
|
2491
|
+
if(existingOutput?.handle){
|
|
2492
|
+
try{
|
|
2493
|
+
await existingOutput.handle.close();
|
|
2494
|
+
}catch(error){
|
|
2495
|
+
cleanupIssues.push(Object.freeze({
|
|
2496
|
+
scope:'artifact-backup-handle',
|
|
2497
|
+
path:promotion?.backupPath??output,
|
|
2498
|
+
message:`Prior-output anchor close warning: ${String(error?.message??error)}`,
|
|
2499
|
+
recovery:'Inspect the preserved prior-output path before removing it.'
|
|
2500
|
+
}));
|
|
2501
|
+
}
|
|
2502
|
+
existingOutput.handle=null;
|
|
2503
|
+
}
|
|
2504
|
+
const lockCleanup=await releaseLock();
|
|
2505
|
+
if(lockCleanup)cleanupIssues.push(lockCleanup);
|
|
2506
|
+
}
|
|
2507
|
+
if(operationError){
|
|
2508
|
+
if(cleanupIssues.length){
|
|
2509
|
+
appendErrorWarning(
|
|
2510
|
+
operationError,
|
|
2511
|
+
`Cleanup warning: ${cleanupIssues.map(issue=>issue.message).join('; ')}`
|
|
2512
|
+
);
|
|
2513
|
+
}
|
|
2514
|
+
throw operationError;
|
|
2515
|
+
}
|
|
2516
|
+
if(cleanupIssues.length){
|
|
2517
|
+
result.cleanup=Object.freeze({
|
|
2518
|
+
status:'degraded',
|
|
2519
|
+
issues:Object.freeze(cleanupIssues)
|
|
2520
|
+
});
|
|
2521
|
+
}
|
|
2522
|
+
return Object.freeze(result);
|
|
2523
|
+
}
|