arcane-os 0.3.1 → 0.3.2
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 +14 -0
- package/README.md +86 -117
- package/bin/arcane-test.mjs +170 -46
- package/browser-runtime/ai/browser-speech-artifacts.mjs +855 -895
- package/browser-runtime/ai/browser-speech-providers.mjs +80 -204
- package/browser-runtime/ai/browser-wasm-llm-provider.mjs +627 -819
- package/browser-runtime/ai/browser-wasm.mjs +24 -35
- package/browser-runtime/ai/browser-wllama-runtime.mjs +64 -316
- package/browser-runtime/ai/model-controller.mjs +584 -181
- package/browser-runtime/ai/speech-worker-client.mjs +8 -148
- package/browser-runtime/ai/speech-worker-runtime.mjs +642 -374
- package/browser-runtime/dom-event-instrumentation.mjs +55 -147
- package/browser-runtime/event-manager.mjs +239 -624
- package/package.json +5 -6
- package/runtime/arcane/components/app-bar.html +3 -15
- package/runtime/arcane/components/assistant-panel.html +10 -10
- package/runtime/arcane/components/calculator.html +1 -1
- package/runtime/arcane/components/chat.html +1359 -135
- package/runtime/arcane/components/conversation-view.html +2 -2
- package/runtime/arcane/components/document-inspector.html +11 -17
- package/runtime/arcane/components/file-manager.html +13 -56
- package/runtime/arcane/components/markdown-document.html +82 -281
- package/runtime/arcane/components/markdown-editor.html +7 -10
- package/runtime/arcane/components/media-embed.html +6 -6
- package/runtime/arcane/components/screen-capture.html +4 -4
- package/runtime/arcane/components/source-explanation.html +2 -2
- package/runtime/arcane/components/speech.html +112 -68
- package/runtime/arcane/components/terminal-workspace.html +4 -4
- package/runtime/arcane/components/theme-editor.html +1 -1
- package/runtime/arcane/components/unified-inbox.html +2 -2
- package/runtime/arcane/components/voice-transcription.html +31 -21
- package/runtime/arcane/entities/Calculation.js +2 -3
- package/runtime/arcane/entities/Chat.js +228 -43
- package/runtime/arcane/entities/Preference.js +3 -5
- package/runtime/arcane/entities/Weather.js +5 -5
- package/runtime/arcane/modules/AI.js +1042 -427
- package/runtime/arcane/modules/AIProviderRuntime.js +618 -359
- package/runtime/arcane/modules/AIResponseLength.js +9 -19
- package/runtime/arcane/modules/AIRuntimeState.js +109 -72
- package/runtime/arcane/modules/ArcaneNavigationPolicy.js +45 -32
- package/runtime/arcane/modules/BrowserTestSuite.js +78 -122
- package/runtime/arcane/modules/CalculatorEngine.js +9 -9
- package/runtime/arcane/modules/CommunicationAppController.js +3 -7
- package/runtime/arcane/modules/ComponentContracts.js +30 -32
- package/runtime/arcane/modules/ConfiguredAIChatSession.js +281 -230
- package/runtime/arcane/modules/ConversationActionItems.js +26 -59
- package/runtime/arcane/modules/ConversationClosingReport.js +34 -61
- package/runtime/arcane/modules/ConversationTimebox.js +27 -15
- package/runtime/arcane/modules/DBOPFSDocumentLibrary.js +152 -344
- package/runtime/arcane/modules/DocumentLexicalSearch.js +25 -91
- package/runtime/arcane/modules/HTMLImport.js +54 -1
- package/runtime/arcane/modules/IsolatedModelQuestionRunner.js +40 -203
- package/runtime/arcane/modules/LocalAIReadiness.js +40 -60
- package/runtime/arcane/modules/LocalAIReadinessController.js +15 -13
- package/runtime/arcane/modules/MD.js +1 -45
- package/runtime/arcane/modules/Mail.js +51 -103
- package/runtime/arcane/modules/MailOutbox.mjs +95 -193
- package/runtime/arcane/modules/MailTransport.mjs +36 -57
- package/runtime/arcane/modules/ModelDefinition.js +22 -106
- package/runtime/arcane/modules/OpenMeteoWeatherProvider.js +39 -101
- package/runtime/arcane/modules/PersistentAIChatSession.js +281 -18
- package/runtime/arcane/modules/PreferenceStore.js +102 -30
- package/runtime/arcane/modules/RiskSignalAnalyzer.js +8 -9
- package/runtime/arcane/modules/ScopedOPFSCache.js +7 -42
- package/runtime/arcane/modules/ScreenCapture.js +175 -128
- package/runtime/arcane/modules/SpeechPlayback.js +46 -149
- package/runtime/arcane/modules/StaticDocumentCatalog.js +173 -407
- package/runtime/arcane/modules/ToolCallRouter.js +25 -12
- package/runtime/arcane/modules/YouTubeMedia.js +6 -5
- package/schemas/arcane-app-bundle.schema.json +13 -78
- package/schemas/arcane-app.schema.json +9 -25
- package/schemas/arcane-lock.schema.json +18 -151
- package/schemas/arcane-package.schema.json +2 -16
- package/schemas/native-build-plan.schema.json +119 -122
- package/src/app-descriptor.mjs +75 -132
- package/src/application-tests.mjs +200 -0
- package/src/cli/main.mjs +27 -46
- package/src/constants.mjs +3 -4
- package/src/dev-server.mjs +30 -324
- package/src/doctor.mjs +92 -154
- package/src/dom-event-instrumentation.mjs +55 -147
- package/src/errors.mjs +2 -3
- package/src/event-manager.mjs +239 -624
- package/src/event-queue.mjs +3 -3
- package/src/import-map.mjs +273 -1028
- package/src/index.mjs +14 -16
- package/src/installed-sdk-runtime.mjs +27 -67
- package/src/integrated-provider-loader.mjs +53 -382
- package/src/mail-api.mjs +0 -2
- package/src/mail-server.mjs +224 -580
- package/src/mail.mjs +4 -10
- package/src/native-plan.mjs +163 -598
- package/src/native-provider-loader.mjs +104 -1063
- package/src/packager/core.mjs +485 -3229
- package/src/process.mjs +5 -10
- package/src/release-bundle.mjs +292 -2405
- package/src/runtime.mjs +76 -396
- package/src/scaffold.mjs +30 -80
- package/src/sdk-browser-runtime.mjs +70 -626
- package/src/source-server.mjs +588 -0
- package/src/targets/index.mjs +78 -188
- package/src/templates/workspace-template.mjs +19 -135
- package/src/testing-loader.mjs +164 -0
- package/src/testing.mjs +1 -1
- package/src/toolchain.mjs +131 -544
- package/src/update-check.mjs +26 -64
- package/src/workspace-operation-lock.mjs +139 -430
- package/src/workspace-runtime.mjs +109 -1558
- package/src/workspace.mjs +40 -302
- package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +0 -218
- package/browser-runtime/ai/ARCANE_AI_BROWSER_SPEECH_COMPONENTS.json +0 -203
- package/browser-runtime/ai/ARCANE_AI_BROWSER_WASM_COMPONENTS.json +0 -80
- package/browser-runtime/ai/internal/sha256.mjs +0 -166
- package/docs/architecture.md +0 -344
- package/docs/compatibility.md +0 -36
- package/docs/event-manager.md +0 -294
- package/docs/platform-targets.md +0 -108
- package/docs/publishing.md +0 -201
- package/docs/reference/README.md +0 -185
- package/docs/reference/ai/browser-speech-package-authority.json +0 -835
- package/docs/reference/ai/browser-speech.md +0 -1295
- package/docs/reference/ai/browser-wasm.md +0 -530
- package/docs/reference/arcane-ollama.md +0 -288
- package/docs/reference/availability-and-normalization.md +0 -183
- package/docs/reference/behavioral-testing.md +0 -133
- package/docs/reference/cli.md +0 -779
- package/docs/reference/core/README.md +0 -62
- package/docs/reference/core/arcane-ai-contracts.md +0 -907
- package/docs/reference/core/arcane-api.md +0 -601
- package/docs/reference/core/arcane-entities.md +0 -65
- package/docs/reference/core/arcane-events.md +0 -134
- package/docs/reference/core/ollama-module.md +0 -181
- package/docs/reference/core/reference/arcane-api/ai-and-ollama.md +0 -1909
- package/docs/reference/core/reference/arcane-api/applications-terminal-capabilities.md +0 -1057
- package/docs/reference/core/reference/arcane-api/core-and-events.md +0 -320
- package/docs/reference/core/reference/arcane-api/filesystem-storage-preferences-appearance.md +0 -610
- package/docs/reference/core/reference/arcane-api/namespaces.md +0 -1157
- package/docs/reference/core/reference/arcane-api/platform-installation-users-system.md +0 -1423
- package/docs/reference/core/reference/arcane-api/session-provisioning-diagnostics-development.md +0 -315
- package/docs/reference/event-manager.md +0 -1511
- package/docs/reference/inventory/package-api.json +0 -3284
- package/docs/reference/inventory/runtime-components.json +0 -1011
- package/docs/reference/inventory/runtime-entities.json +0 -26
- package/docs/reference/inventory/runtime-modules.json +0 -1431
- package/docs/reference/mail.md +0 -316
- package/docs/reference/protocols.md +0 -719
- package/docs/reference/runtime-components.md +0 -1366
- package/docs/reference/runtime-entities.md +0 -303
- package/docs/reference/runtime-modules.md +0 -2965
- package/docs/reference/sdk-api.md +0 -6698
- package/docs/roadmap.md +0 -79
- package/docs/work-amplification.md +0 -129
- package/runtime/ARCANE_RUNTIME_RELEASE.json +0 -826
package/src/release-bundle.mjs
CHANGED
|
@@ -1,28 +1,19 @@
|
|
|
1
|
-
import {createHash,randomBytes} from 'node:crypto';
|
|
2
|
-
import {once} from 'node:events';
|
|
3
|
-
import {constants as FS_CONSTANTS} from 'node:fs';
|
|
4
1
|
import {
|
|
5
|
-
link,
|
|
6
2
|
lstat,
|
|
7
3
|
mkdir,
|
|
8
|
-
|
|
4
|
+
readFile,
|
|
5
|
+
readdir,
|
|
9
6
|
realpath,
|
|
10
|
-
|
|
7
|
+
rename,
|
|
8
|
+
rm,
|
|
9
|
+
writeFile
|
|
11
10
|
} from 'node:fs/promises';
|
|
12
11
|
import path from 'node:path';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {createDeflateRaw,createGunzip} from 'node:zlib';
|
|
16
|
-
|
|
17
|
-
import {appDescriptorSha256,projectPackageManifest,validateAppDescriptor} from './app-descriptor.mjs';
|
|
12
|
+
import {gzipSync,gunzipSync} from 'node:zlib';
|
|
13
|
+
import {validateAppDescriptor} from './app-descriptor.mjs';
|
|
18
14
|
import {SDK_NAME,SDK_VERSION} from './constants.mjs';
|
|
19
15
|
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';
|
|
16
|
+
import {RELEASE_MANIFEST_NAME,PACKAGER_VERSION,parseSemver} from './packager/core.mjs';
|
|
26
17
|
|
|
27
18
|
export const APP_BUNDLE_MANIFEST_NAME='ARCANE_APP_BUNDLE.json';
|
|
28
19
|
export const APP_BUNDLE_DESCRIPTOR_NAME='arcane-app.json';
|
|
@@ -31,2493 +22,389 @@ export const APP_BUNDLE_KIND='arcane-app-release-bundle';
|
|
|
31
22
|
export const APP_BUNDLE_FORMAT='ustar+gzip';
|
|
32
23
|
export const APP_BUNDLE_SCHEMA_VERSION=1;
|
|
33
24
|
export const APP_BUNDLE_EXTENSION='.arcane-app.tar.gz';
|
|
34
|
-
export const APP_BUNDLE_SUPPORTED_SDK_VERSIONS=
|
|
25
|
+
export const APP_BUNDLE_SUPPORTED_SDK_VERSIONS=[SDK_VERSION];
|
|
35
26
|
|
|
36
|
-
|
|
37
|
-
|
|
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;
|
|
27
|
+
const TAR_BLOCK_SIZE=512;
|
|
28
|
+
const TAR_END_SIZE=TAR_BLOCK_SIZE*2;
|
|
50
29
|
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
30
|
const WINDOWS_RESERVED_PATTERN=
|
|
56
31
|
/^(?:con|prn|aux|nul|clock\$|conin\$|conout\$|com[1-9¹²³]|lpt[1-9¹²³])(?:[.]|$)/iu;
|
|
57
32
|
const WINDOWS_UNSAFE_FILENAME_CHARACTER_PATTERN=/[<>"|?*]/u;
|
|
58
|
-
const PORTABLE_RESERVED_FILENAME_PATTERN=WINDOWS_RESERVED_PATTERN;
|
|
59
33
|
const CONTROL_PATHS=new Set([
|
|
60
34
|
APP_BUNDLE_MANIFEST_NAME,
|
|
61
35
|
APP_BUNDLE_DESCRIPTOR_NAME,
|
|
62
36
|
APP_BUNDLE_RELEASE_PATH
|
|
63
37
|
]);
|
|
64
|
-
const textDecoder=new TextDecoder('utf-8',{fatal:true});
|
|
65
38
|
|
|
66
|
-
function fail(message,code=
|
|
39
|
+
function fail(message,code='ARCANE_BUNDLE_INVALID',details){
|
|
67
40
|
throw new ArcaneError(code,message,{details});
|
|
68
41
|
}
|
|
69
42
|
|
|
70
|
-
function appendErrorWarning(error,warning){
|
|
71
|
-
try{
|
|
72
|
-
error.message=`${String(error?.message??error)} ${warning}`;
|
|
73
|
-
}catch{}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
43
|
async function emit(onEvent,event){
|
|
77
|
-
await onEvent
|
|
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');
|
|
44
|
+
if(typeof onEvent==='function')await onEvent(event);
|
|
86
45
|
}
|
|
87
46
|
|
|
88
47
|
function compareText(left,right){
|
|
89
|
-
|
|
48
|
+
const a=String(left);
|
|
49
|
+
const b=String(right);
|
|
50
|
+
return a<b?-1:a>b?1:0;
|
|
90
51
|
}
|
|
91
52
|
|
|
92
53
|
function isPlainObject(value){
|
|
93
|
-
|
|
94
|
-
const prototype=Object.getPrototypeOf(value);
|
|
95
|
-
return prototype===Object.prototype||prototype===null;
|
|
54
|
+
return value!==null&&typeof value==='object'&&!Array.isArray(value);
|
|
96
55
|
}
|
|
97
56
|
|
|
98
|
-
function
|
|
99
|
-
|
|
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
|
-
}
|
|
57
|
+
function copyJson(value){
|
|
58
|
+
return JSON.parse(JSON.stringify(value));
|
|
116
59
|
}
|
|
117
60
|
|
|
118
61
|
function pathKey(value){
|
|
119
|
-
return value.normalize('NFC').
|
|
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);
|
|
62
|
+
return value.normalize('NFC').toLocaleLowerCase('en-US');
|
|
148
63
|
}
|
|
149
64
|
|
|
150
65
|
export function validateAppBundlePath(value,label='bundle path'){
|
|
151
|
-
if(typeof value!=='string'||!value||value!==value.normalize('NFC')
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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.`);
|
|
66
|
+
if(typeof value!=='string'||!value||value!==value.normalize('NFC')
|
|
67
|
+
||value.includes('\\')||value.startsWith('/')||/^[a-z]:/iu.test(value)
|
|
68
|
+
||/[\u0000-\u001f\u007f]/u.test(value)){
|
|
69
|
+
fail(`Unsafe ${label}: ${String(value)}.`);
|
|
161
70
|
}
|
|
162
71
|
const segments=value.split('/');
|
|
163
|
-
|
|
164
|
-
||
|
|
165
|
-
|
|
166
|
-
|
|
72
|
+
for(const segment of segments){
|
|
73
|
+
if(!segment||segment==='.'||segment==='..'||segment.endsWith('.')||segment.endsWith(' ')
|
|
74
|
+
||segment.includes(':')||WINDOWS_UNSAFE_FILENAME_CHARACTER_PATTERN.test(segment)
|
|
75
|
+
||WINDOWS_RESERVED_PATTERN.test(segment)){
|
|
76
|
+
fail(`Unsafe ${label}: ${value}.`);
|
|
77
|
+
}
|
|
167
78
|
}
|
|
168
|
-
return
|
|
79
|
+
return segments.join('/');
|
|
169
80
|
}
|
|
170
81
|
|
|
171
|
-
function splitUstarPath(
|
|
172
|
-
validateAppBundlePath(
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if(
|
|
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){
|
|
82
|
+
function splitUstarPath(archivePath){
|
|
83
|
+
const normalized=validateAppBundlePath(archivePath,'archive path');
|
|
84
|
+
if(Buffer.byteLength(normalized,'utf8')<=100)return {name:normalized,prefix:''};
|
|
85
|
+
const segments=normalized.split('/');
|
|
86
|
+
for(let index=segments.length-1;index>0;index-=1){
|
|
87
|
+
const prefix=segments.slice(0,index).join('/');
|
|
88
|
+
const name=segments.slice(index).join('/');
|
|
89
|
+
if(Buffer.byteLength(prefix,'utf8')<=155&&Buffer.byteLength(name,'utf8')<=100){
|
|
182
90
|
return {name,prefix};
|
|
183
91
|
}
|
|
184
92
|
}
|
|
185
|
-
fail(`
|
|
93
|
+
fail(`Archive path cannot be represented by ustar: ${normalized}.`);
|
|
186
94
|
}
|
|
187
95
|
|
|
188
|
-
function
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
header.write(`${octal.padStart(length-1,'0')}\0`,offset,length,'ascii');
|
|
96
|
+
function writeTextField(header,offset,length,value,label){
|
|
97
|
+
const encoded=Buffer.from(String(value),'utf8');
|
|
98
|
+
if(encoded.length>length)fail(`${label} does not fit its ustar field.`);
|
|
99
|
+
encoded.copy(header,offset);
|
|
193
100
|
}
|
|
194
101
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
102
|
+
function writeOctalField(header,offset,length,value,label,{trailingSpace=false}={}){
|
|
103
|
+
if(!Number.isSafeInteger(value)||value<0)fail(`${label} must be a nonnegative integer.`);
|
|
104
|
+
const terminal=trailingSpace?'\0 ':'\0';
|
|
105
|
+
const digits=value.toString(8);
|
|
106
|
+
const available=length-terminal.length;
|
|
107
|
+
if(digits.length>available)fail(`${label} does not fit its ustar field.`);
|
|
108
|
+
const rendered=`${digits.padStart(available,'0')}${terminal}`;
|
|
109
|
+
header.write(rendered,offset,length,'ascii');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function createCanonicalUstarHeader(archivePath,size){
|
|
113
|
+
const {name,prefix}=splitUstarPath(archivePath);
|
|
114
|
+
const header=Buffer.alloc(TAR_BLOCK_SIZE);
|
|
115
|
+
writeTextField(header,0,100,name,'ustar name');
|
|
116
|
+
writeOctalField(header,100,8,ARCHIVE_MODE,'ustar mode');
|
|
117
|
+
writeOctalField(header,108,8,0,'ustar uid');
|
|
118
|
+
writeOctalField(header,116,8,0,'ustar gid');
|
|
119
|
+
writeOctalField(header,124,12,size,'ustar size');
|
|
120
|
+
writeOctalField(header,136,12,0,'ustar mtime');
|
|
205
121
|
header.fill(0x20,148,156);
|
|
206
122
|
header[156]=0x30;
|
|
207
123
|
header.write('ustar\0',257,6,'ascii');
|
|
208
124
|
header.write('00',263,2,'ascii');
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
125
|
+
writeTextField(header,265,32,'root','ustar owner');
|
|
126
|
+
writeTextField(header,297,32,'root','ustar group');
|
|
127
|
+
writeTextField(header,345,155,prefix,'ustar prefix');
|
|
128
|
+
let checksum=0;
|
|
129
|
+
for(const value of header)checksum+=value;
|
|
130
|
+
writeOctalField(header,148,8,checksum,'ustar checksum',{trailingSpace:true});
|
|
214
131
|
return header;
|
|
215
132
|
}
|
|
216
133
|
|
|
217
|
-
function
|
|
218
|
-
const
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
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;
|
|
134
|
+
function tarEntry(archivePath,content){
|
|
135
|
+
const header=createCanonicalUstarHeader(archivePath,content.length);
|
|
136
|
+
const remainder=content.length%TAR_BLOCK_SIZE;
|
|
137
|
+
const padding=remainder===0?Buffer.alloc(0):Buffer.alloc(TAR_BLOCK_SIZE-remainder);
|
|
138
|
+
return Buffer.concat([header,content,padding]);
|
|
311
139
|
}
|
|
312
140
|
|
|
313
|
-
async function
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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.`);
|
|
141
|
+
async function realDirectory(location,label){
|
|
142
|
+
const requested=path.resolve(location);
|
|
143
|
+
let info;
|
|
144
|
+
try{info=await lstat(requested);}
|
|
145
|
+
catch(error){
|
|
146
|
+
if(error?.code==='ENOENT')fail(`${label} does not exist: ${requested}.`);
|
|
376
147
|
throw error;
|
|
377
148
|
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
}
|
|
383
|
-
return {handle,identity:fileIdentity(opened)};
|
|
384
|
-
}catch(error){
|
|
385
|
-
await handle.close().catch(()=>{});
|
|
386
|
-
throw error;
|
|
149
|
+
if(info.isSymbolicLink()||!info.isDirectory())fail(`${label} must be a real directory.`);
|
|
150
|
+
const canonical=await realpath(requested);
|
|
151
|
+
const canonicalInfo=await lstat(canonical);
|
|
152
|
+
if(canonicalInfo.isSymbolicLink()||!canonicalInfo.isDirectory()){
|
|
153
|
+
fail(`${label} must be a real directory.`);
|
|
387
154
|
}
|
|
155
|
+
return canonical;
|
|
388
156
|
}
|
|
389
157
|
|
|
390
|
-
async function
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
while(position<bytes.length){
|
|
158
|
+
async function listReleaseFiles(releaseRoot,{signal}={}){
|
|
159
|
+
const files=[];
|
|
160
|
+
async function visit(directory,relativeRoot=''){
|
|
394
161
|
throwIfAborted(signal);
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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.`);
|
|
162
|
+
const entries=await readdir(directory,{withFileTypes:true});
|
|
163
|
+
entries.sort((left,right)=>compareText(left.name,right.name));
|
|
164
|
+
for(const entry of entries){
|
|
165
|
+
const relative=relativeRoot?`${relativeRoot}/${entry.name}`:entry.name;
|
|
166
|
+
validateAppBundlePath(relative,'release path');
|
|
167
|
+
const absolute=path.join(directory,entry.name);
|
|
168
|
+
const info=await lstat(absolute);
|
|
169
|
+
if(info.isSymbolicLink())fail(`Release contains a symbolic link: ${relative}.`);
|
|
170
|
+
if(info.isDirectory())await visit(absolute,relative);
|
|
171
|
+
else if(info.isFile())files.push({path:relative,absolute});
|
|
172
|
+
else fail(`Release contains a non-file entry: ${relative}.`);
|
|
419
173
|
}
|
|
420
|
-
return bytes;
|
|
421
|
-
}finally{
|
|
422
|
-
await opened.handle.close();
|
|
423
174
|
}
|
|
175
|
+
await visit(releaseRoot);
|
|
176
|
+
return files.sort((left,right)=>compareText(left.path,right.path));
|
|
424
177
|
}
|
|
425
178
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
}
|
|
179
|
+
function readJsonContent(content,label){
|
|
180
|
+
try{return JSON.parse(content.toString('utf8'));}
|
|
181
|
+
catch(error){fail(`${label} is not valid JSON: ${error.message}.`);}
|
|
464
182
|
}
|
|
465
183
|
|
|
466
|
-
function
|
|
467
|
-
if(!
|
|
468
|
-
||
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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);
|
|
184
|
+
function validateReleaseManifest(value){
|
|
185
|
+
if(!isPlainObject(value)||value.schemaVersion!==1||value.kind!=='arcane-app-release'
|
|
186
|
+
||value.packagerVersion!==PACKAGER_VERSION||!isPlainObject(value.app)
|
|
187
|
+
||typeof value.app.id!=='string'||typeof value.app.version!=='string'
|
|
188
|
+
||!Array.isArray(value.files)){
|
|
189
|
+
fail(`${RELEASE_MANIFEST_NAME} is malformed.`);
|
|
502
190
|
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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.');
|
|
191
|
+
parseSemver(value.app.version);
|
|
192
|
+
const files=value.files.map((entry,index)=>validateAppBundlePath(
|
|
193
|
+
entry,
|
|
194
|
+
`${RELEASE_MANIFEST_NAME}.files[${index}]`
|
|
195
|
+
)).sort(compareText);
|
|
196
|
+
if(new Set(files.map(pathKey)).size!==files.length){
|
|
197
|
+
fail(`${RELEASE_MANIFEST_NAME} contains duplicate files.`);
|
|
529
198
|
}
|
|
530
|
-
return
|
|
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
|
-
});
|
|
199
|
+
return {...copyJson(value),files};
|
|
540
200
|
}
|
|
541
201
|
|
|
542
|
-
function
|
|
543
|
-
|
|
544
|
-
return Object.freeze({
|
|
202
|
+
function bundleManifest(descriptor,release,files){
|
|
203
|
+
return {
|
|
545
204
|
schemaVersion:APP_BUNDLE_SCHEMA_VERSION,
|
|
546
205
|
kind:APP_BUNDLE_KIND,
|
|
547
206
|
format:APP_BUNDLE_FORMAT,
|
|
548
|
-
sdk:
|
|
549
|
-
app:
|
|
550
|
-
descriptor:
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
}
|
|
207
|
+
sdk:{name:SDK_NAME,version:SDK_VERSION},
|
|
208
|
+
app:{id:descriptor.id,version:descriptor.version},
|
|
209
|
+
descriptor:APP_BUNDLE_DESCRIPTOR_NAME,
|
|
210
|
+
release:APP_BUNDLE_RELEASE_PATH,
|
|
211
|
+
files:[...files]
|
|
807
212
|
};
|
|
808
213
|
}
|
|
809
214
|
|
|
810
|
-
async function
|
|
215
|
+
async function outputBoundary(outputPath,overwrite){
|
|
216
|
+
if(typeof outputPath!=='string'||!outputPath.trim())fail('outputPath is required.');
|
|
217
|
+
if(typeof overwrite!=='boolean')fail('overwrite must be a boolean.',ERROR_CODES.usage);
|
|
218
|
+
const resolved=path.resolve(outputPath);
|
|
219
|
+
await mkdir(path.dirname(resolved),{recursive:true});
|
|
220
|
+
const parent=await realDirectory(path.dirname(resolved),'Bundle output directory');
|
|
221
|
+
const selected=path.join(parent,path.basename(resolved));
|
|
811
222
|
try{
|
|
812
|
-
const info=await lstat(
|
|
813
|
-
if(info.isSymbolicLink()||!info.isFile()
|
|
814
|
-
|
|
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
|
-
}
|
|
223
|
+
const info=await lstat(selected);
|
|
224
|
+
if(info.isSymbolicLink()||!info.isFile())fail('Existing bundle output must be a real file.');
|
|
225
|
+
if(!overwrite)fail(`Bundle output already exists: ${selected}.`,ERROR_CODES.usage);
|
|
1047
226
|
}catch(error){
|
|
1048
|
-
|
|
1049
|
-
issue:changedStagingCleanupIssue(
|
|
1050
|
-
temporary,
|
|
1051
|
-
`Anchored staging cleanup warning for ${temporary}: ${String(error?.message??error)}`
|
|
1052
|
-
)
|
|
1053
|
-
});
|
|
227
|
+
if(error?.code!=='ENOENT')throw error;
|
|
1054
228
|
}
|
|
229
|
+
return selected;
|
|
1055
230
|
}
|
|
1056
231
|
|
|
1057
|
-
async function
|
|
1058
|
-
temporary
|
|
1059
|
-
|
|
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
|
-
};
|
|
232
|
+
async function writeBundle(outputPath,content,{overwrite}){
|
|
233
|
+
const temporary=`${outputPath}.staging-${process.pid}-${Date.now()}`;
|
|
234
|
+
await writeFile(temporary,content,{flag:'wx'});
|
|
1076
235
|
try{
|
|
1077
|
-
|
|
1078
|
-
|
|
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
|
-
}
|
|
236
|
+
if(overwrite)await rm(outputPath,{force:true});
|
|
237
|
+
await rename(temporary,outputPath);
|
|
1143
238
|
}catch(error){
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
return changedStagingCleanupIssue(
|
|
1147
|
-
temporary,
|
|
1148
|
-
`Staging cleanup warning for ${temporary}: ${String(error?.message??error)}`
|
|
1149
|
-
+(closeWarning?` ${closeWarning}`:'')
|
|
1150
|
-
);
|
|
239
|
+
await rm(temporary,{force:true}).catch(()=>{});
|
|
240
|
+
throw error;
|
|
1151
241
|
}
|
|
1152
242
|
}
|
|
1153
243
|
|
|
1154
|
-
async function
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
signal
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
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
|
-
);
|
|
244
|
+
export async function createAppReleaseBundle({
|
|
245
|
+
releaseRoot,
|
|
246
|
+
appDescriptor,
|
|
247
|
+
outputPath,
|
|
248
|
+
overwrite=false,
|
|
249
|
+
signal,
|
|
250
|
+
onEvent
|
|
251
|
+
}={}){
|
|
252
|
+
throwIfAborted(signal);
|
|
253
|
+
const canonicalReleaseRoot=await realDirectory(releaseRoot,'App release root');
|
|
254
|
+
const files=await listReleaseFiles(canonicalReleaseRoot,{signal});
|
|
255
|
+
const releaseFile=files.find(file=>pathKey(file.path)===pathKey(RELEASE_MANIFEST_NAME));
|
|
256
|
+
if(!releaseFile)fail(`App release is missing ${RELEASE_MANIFEST_NAME}.`);
|
|
257
|
+
const release=validateReleaseManifest(readJsonContent(
|
|
258
|
+
await readFile(releaseFile.absolute),
|
|
259
|
+
RELEASE_MANIFEST_NAME
|
|
260
|
+
));
|
|
261
|
+
const descriptor=copyJson(validateAppDescriptor(copyJson(appDescriptor),{appId:release.app.id}));
|
|
262
|
+
if(descriptor.version!==release.app.version){
|
|
263
|
+
fail('App descriptor and release manifest versions differ.');
|
|
264
|
+
}
|
|
265
|
+
const payloadPaths=files.map(file=>`payload/${file.path}`);
|
|
266
|
+
const manifest=bundleManifest(descriptor,release,payloadPaths);
|
|
267
|
+
const entries=[
|
|
268
|
+
tarEntry(APP_BUNDLE_MANIFEST_NAME,Buffer.from(`${JSON.stringify(manifest,null,2)}\n`,'utf8')),
|
|
269
|
+
tarEntry(APP_BUNDLE_DESCRIPTOR_NAME,Buffer.from(`${JSON.stringify(descriptor,null,2)}\n`,'utf8'))
|
|
270
|
+
];
|
|
271
|
+
for(const file of files){
|
|
1347
272
|
throwIfAborted(signal);
|
|
1348
|
-
await
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
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
|
|
273
|
+
entries.push(tarEntry(`payload/${file.path}`,await readFile(file.absolute)));
|
|
274
|
+
}
|
|
275
|
+
entries.push(Buffer.alloc(TAR_END_SIZE));
|
|
276
|
+
const output=await outputBoundary(outputPath,overwrite);
|
|
277
|
+
await writeBundle(output,gzipSync(Buffer.concat(entries),{mtime:0}),{overwrite});
|
|
278
|
+
await emit(onEvent,{
|
|
279
|
+
type:'bundle.completed',
|
|
280
|
+
phase:'publish',
|
|
281
|
+
status:'completed',
|
|
282
|
+
bundlePath:output,
|
|
283
|
+
files:[...manifest.files]
|
|
1435
284
|
});
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
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
|
-
}
|
|
285
|
+
return {
|
|
286
|
+
bundlePath:output,
|
|
287
|
+
manifest:copyJson(manifest),
|
|
288
|
+
descriptor:copyJson(descriptor),
|
|
289
|
+
release:copyJson(release),
|
|
290
|
+
files:[...manifest.files]
|
|
291
|
+
};
|
|
1655
292
|
}
|
|
1656
293
|
|
|
1657
|
-
|
|
1658
|
-
const
|
|
1659
|
-
const
|
|
1660
|
-
|
|
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));
|
|
294
|
+
function readStringField(header,offset,length){
|
|
295
|
+
const end=header.indexOf(0,offset);
|
|
296
|
+
const selectedEnd=end<0||end>offset+length?offset+length:end;
|
|
297
|
+
return header.subarray(offset,selectedEnd).toString('utf8');
|
|
1687
298
|
}
|
|
1688
299
|
|
|
1689
|
-
function
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
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
|
-
}
|
|
300
|
+
function readOctalField(header,offset,length,label){
|
|
301
|
+
const text=header.subarray(offset,offset+length).toString('ascii').replace(/[\0 ]+$/u,'');
|
|
302
|
+
if(!/^[0-7]+$/u.test(text))fail(`${label} is not a valid ustar octal field.`);
|
|
303
|
+
const value=Number.parseInt(text,8);
|
|
304
|
+
if(!Number.isSafeInteger(value))fail(`${label} exceeds the supported integer range.`);
|
|
1705
305
|
return value;
|
|
1706
306
|
}
|
|
1707
307
|
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
);
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
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
|
|
308
|
+
function readTarEntries(archive){
|
|
309
|
+
const entries=new Map();
|
|
310
|
+
let offset=0;
|
|
311
|
+
while(offset+TAR_BLOCK_SIZE<=archive.length){
|
|
312
|
+
const header=archive.subarray(offset,offset+TAR_BLOCK_SIZE);
|
|
313
|
+
if(header.every(value=>value===0))break;
|
|
314
|
+
if(readStringField(header,257,6)!=='ustar')fail('Bundle is not a ustar archive.');
|
|
315
|
+
const expectedChecksum=readOctalField(header,148,8,'ustar checksum');
|
|
316
|
+
const checksumHeader=Buffer.from(header);
|
|
317
|
+
checksumHeader.fill(0x20,148,156);
|
|
318
|
+
let actualChecksum=0;
|
|
319
|
+
for(const value of checksumHeader)actualChecksum+=value;
|
|
320
|
+
if(actualChecksum!==expectedChecksum)fail('Bundle contains a malformed ustar header.');
|
|
321
|
+
if(header[156]!==0&&header[156]!==0x30)fail('Bundle contains a non-file archive entry.');
|
|
322
|
+
const name=readStringField(header,0,100);
|
|
323
|
+
const prefix=readStringField(header,345,155);
|
|
324
|
+
const archivePath=validateAppBundlePath(prefix?`${prefix}/${name}`:name,'archive path');
|
|
325
|
+
if(entries.has(pathKey(archivePath)))fail(`Bundle contains a duplicate path: ${archivePath}.`);
|
|
326
|
+
const size=readOctalField(header,124,12,'ustar size');
|
|
327
|
+
const contentStart=offset+TAR_BLOCK_SIZE;
|
|
328
|
+
const contentEnd=contentStart+size;
|
|
329
|
+
if(contentEnd>archive.length)fail(`Bundle entry is incomplete: ${archivePath}.`);
|
|
330
|
+
entries.set(pathKey(archivePath),{
|
|
331
|
+
path:archivePath,
|
|
332
|
+
content:Buffer.from(archive.subarray(contentStart,contentEnd))
|
|
1882
333
|
});
|
|
334
|
+
offset=contentStart+Math.ceil(size/TAR_BLOCK_SIZE)*TAR_BLOCK_SIZE;
|
|
1883
335
|
}
|
|
1884
|
-
|
|
1885
|
-
destroy(){
|
|
1886
|
-
this.deflate.destroy();
|
|
1887
|
-
}
|
|
336
|
+
return entries;
|
|
1888
337
|
}
|
|
1889
338
|
|
|
1890
|
-
function
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
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
|
-
});
|
|
339
|
+
function requiredEntry(entries,entryPath){
|
|
340
|
+
const entry=entries.get(pathKey(entryPath));
|
|
341
|
+
if(!entry)fail(`Bundle is missing ${entryPath}.`);
|
|
342
|
+
return entry;
|
|
2119
343
|
}
|
|
2120
344
|
|
|
2121
345
|
export async function verifyAppReleaseBundle({bundlePath,signal,onEvent}={}){
|
|
2122
346
|
throwIfAborted(signal);
|
|
2123
|
-
if(typeof bundlePath!=='string'||!bundlePath.trim())
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
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}
|
|
347
|
+
if(typeof bundlePath!=='string'||!bundlePath.trim())fail('bundlePath is required.');
|
|
348
|
+
const selected=path.resolve(bundlePath);
|
|
349
|
+
const info=await lstat(selected);
|
|
350
|
+
if(info.isSymbolicLink()||!info.isFile())fail('Bundle path must be a real file.');
|
|
351
|
+
let archive;
|
|
352
|
+
try{archive=gunzipSync(await readFile(selected));}
|
|
353
|
+
catch(error){fail(`Bundle is not valid gzip data: ${error.message}.`);}
|
|
354
|
+
const entries=readTarEntries(archive);
|
|
355
|
+
const manifest=readJsonContent(
|
|
356
|
+
requiredEntry(entries,APP_BUNDLE_MANIFEST_NAME).content,
|
|
357
|
+
APP_BUNDLE_MANIFEST_NAME
|
|
2273
358
|
);
|
|
2274
|
-
if(!
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
fail(
|
|
2280
|
-
}
|
|
2281
|
-
const
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
if(
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
{
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
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
|
-
);
|
|
359
|
+
if(!isPlainObject(manifest)||manifest.schemaVersion!==APP_BUNDLE_SCHEMA_VERSION
|
|
360
|
+
||manifest.kind!==APP_BUNDLE_KIND||manifest.format!==APP_BUNDLE_FORMAT
|
|
361
|
+
||manifest.sdk?.name!==SDK_NAME||manifest.sdk?.version!==SDK_VERSION
|
|
362
|
+
||manifest.descriptor!==APP_BUNDLE_DESCRIPTOR_NAME
|
|
363
|
+
||manifest.release!==APP_BUNDLE_RELEASE_PATH||!Array.isArray(manifest.files)){
|
|
364
|
+
fail(`${APP_BUNDLE_MANIFEST_NAME} is malformed.`);
|
|
365
|
+
}
|
|
366
|
+
const descriptor=copyJson(validateAppDescriptor(readJsonContent(
|
|
367
|
+
requiredEntry(entries,APP_BUNDLE_DESCRIPTOR_NAME).content,
|
|
368
|
+
APP_BUNDLE_DESCRIPTOR_NAME
|
|
369
|
+
),{appId:manifest.app?.id}));
|
|
370
|
+
const release=validateReleaseManifest(readJsonContent(
|
|
371
|
+
requiredEntry(entries,APP_BUNDLE_RELEASE_PATH).content,
|
|
372
|
+
APP_BUNDLE_RELEASE_PATH
|
|
373
|
+
));
|
|
374
|
+
if(descriptor.id!==manifest.app.id||descriptor.version!==manifest.app.version
|
|
375
|
+
||release.app.id!==manifest.app.id||release.app.version!==manifest.app.version){
|
|
376
|
+
fail('Bundle control records describe different applications.');
|
|
377
|
+
}
|
|
378
|
+
const declared=manifest.files.map((entry,index)=>validateAppBundlePath(
|
|
379
|
+
entry,
|
|
380
|
+
`${APP_BUNDLE_MANIFEST_NAME}.files[${index}]`
|
|
381
|
+
)).sort(compareText);
|
|
382
|
+
const actual=[...entries.values()]
|
|
383
|
+
.map(entry=>entry.path)
|
|
384
|
+
.filter(entry=>!CONTROL_PATHS.has(entry))
|
|
385
|
+
.concat(APP_BUNDLE_RELEASE_PATH)
|
|
386
|
+
.sort(compareText);
|
|
387
|
+
if(JSON.stringify(declared)!==JSON.stringify(actual)){
|
|
388
|
+
fail('Bundle payload inventory differs from its manifest.');
|
|
389
|
+
}
|
|
390
|
+
const releasePayload=declared
|
|
391
|
+
.filter(entry=>entry!==APP_BUNDLE_RELEASE_PATH)
|
|
392
|
+
.map(entry=>entry.slice('payload/'.length))
|
|
393
|
+
.sort(compareText);
|
|
394
|
+
if(JSON.stringify(releasePayload)!==JSON.stringify([...release.files].sort(compareText))){
|
|
395
|
+
fail('Bundled release files differ from the release manifest.');
|
|
396
|
+
}
|
|
397
|
+
await emit(onEvent,{type:'bundle.inspected',bundlePath:selected,files:[...declared]});
|
|
398
|
+
return {
|
|
399
|
+
verified:true,
|
|
400
|
+
bundlePath:selected,
|
|
401
|
+
manifest:copyJson(manifest),
|
|
402
|
+
descriptor:copyJson(descriptor),
|
|
403
|
+
release:copyJson(release),
|
|
404
|
+
files:[...declared],
|
|
405
|
+
readFile(relativePath){
|
|
406
|
+
const normalized=validateAppBundlePath(relativePath,'bundle file path');
|
|
407
|
+
return Buffer.from(requiredEntry(entries,normalized).content);
|
|
2513
408
|
}
|
|
2514
|
-
|
|
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);
|
|
409
|
+
};
|
|
2523
410
|
}
|