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