@devicecloud.dev/dcd 5.2.0 → 5.3.1-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/cloud.d.ts +4 -8
- package/dist/commands/cloud.js +10 -9
- package/dist/commands/upload.d.ts +4 -0
- package/dist/commands/upload.js +3 -0
- package/dist/config/environments.d.ts +17 -0
- package/dist/config/environments.js +2 -0
- package/dist/config/flags/binary.flags.d.ts +4 -0
- package/dist/config/flags/binary.flags.js +4 -0
- package/dist/config/flags/environment.flags.d.ts +0 -8
- package/dist/config/flags/environment.flags.js +0 -8
- package/dist/constants.d.ts +4 -8
- package/dist/gateways/api-gateway.d.ts +43 -1
- package/dist/gateways/api-gateway.js +98 -3
- package/dist/mcp/tools/run-cloud-test.d.ts +1 -1
- package/dist/mcp/tools/run-cloud-test.js +8 -1
- package/dist/methods.d.ts +6 -0
- package/dist/methods.js +103 -13
- package/dist/services/execution-plan.service.js +55 -37
- package/dist/services/execution-plan.utils.d.ts +15 -0
- package/dist/services/execution-plan.utils.js +47 -0
- package/dist/services/test-submission.service.d.ts +7 -2
- package/dist/services/test-submission.service.js +40 -7
- package/dist/types/domain/device.types.d.ts +1 -0
- package/dist/types/domain/device.types.js +1 -0
- package/dist/types/generated/schema.types.d.ts +3531 -873
- package/dist/types.d.ts +7 -0
- package/dist/utils/envelope.d.ts +73 -0
- package/dist/utils/envelope.js +220 -0
- package/package.json +4 -5
package/dist/methods.js
CHANGED
|
@@ -11,6 +11,7 @@ import { inferEnvFromApiUrl } from './config/environments.js';
|
|
|
11
11
|
import { ApiError, ApiGateway } from './gateways/api-gateway.js';
|
|
12
12
|
import { SupabaseGateway } from './gateways/supabase-gateway.js';
|
|
13
13
|
import { MetadataExtractorService } from './services/metadata-extractor.service.js';
|
|
14
|
+
import { encryptFileToPath, generateDek, isEncryptionEnabled, resolveKekPublicKey, wrapDek, } from './utils/envelope.js';
|
|
14
15
|
import { colors, formatId } from './utils/styling.js';
|
|
15
16
|
const mimeTypeLookupByExtension = {
|
|
16
17
|
apk: 'application/vnd.android.package-archive',
|
|
@@ -68,7 +69,7 @@ export const compressFilesFromRelativePath = async (basePath, files, commonRoot)
|
|
|
68
69
|
return zipToBuffer(zipfile);
|
|
69
70
|
};
|
|
70
71
|
export const verifyAppZip = async (zipPath) => {
|
|
71
|
-
// eslint-disable-next-line
|
|
72
|
+
// eslint-disable-next-line new-cap
|
|
72
73
|
const zip = await new StreamZip.async({
|
|
73
74
|
file: zipPath,
|
|
74
75
|
storeEntries: true,
|
|
@@ -94,6 +95,7 @@ export const verifyAppZip = async (zipPath) => {
|
|
|
94
95
|
};
|
|
95
96
|
export const uploadBinary = async (config) => {
|
|
96
97
|
const { filePath, apiUrl, auth, ignoreShaCheck = false, log = true, debug = false } = config;
|
|
98
|
+
const encrypt = isEncryptionEnabled(config.encrypt);
|
|
97
99
|
if (log) {
|
|
98
100
|
ux.action.start(colors.bold('Checking and uploading binary'), colors.dim('Initializing'), {
|
|
99
101
|
stdout: true,
|
|
@@ -107,14 +109,20 @@ export const uploadBinary = async (config) => {
|
|
|
107
109
|
}
|
|
108
110
|
const startTime = Date.now();
|
|
109
111
|
let source;
|
|
112
|
+
let encCleanupDir;
|
|
113
|
+
let enc;
|
|
110
114
|
try {
|
|
111
115
|
// Prepare file for upload
|
|
112
116
|
source = await prepareFileForUpload(filePath, debug, startTime);
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
117
|
+
// Hash the PLAINTEXT first, before any encryption (dcd#1168). Encryption
|
|
118
|
+
// uses a fresh random DEK per upload, so the ciphertext hash differs every
|
|
119
|
+
// time and cannot dedup — the plaintext hash is the only stable key. Doing it
|
|
120
|
+
// in this order also means a dedup hit skips the encryption work entirely,
|
|
121
|
+
// not just the upload.
|
|
122
|
+
const shaPlain = await calculateFileHash(source, debug, log);
|
|
123
|
+
// Check for an existing upload before spending anything on encryption.
|
|
124
|
+
if (!ignoreShaCheck && shaPlain) {
|
|
125
|
+
const { exists, binaryId } = await checkExistingUpload(apiUrl, auth, encrypt ? { encrypted: true, shaPlain } : { sha: shaPlain }, debug);
|
|
118
126
|
if (exists && binaryId) {
|
|
119
127
|
if (log) {
|
|
120
128
|
ux.info(colors.dim('SHA hash matches existing binary with ID: ') + formatId(binaryId) + colors.dim(', skipping upload. Force upload with --ignore-sha-check'));
|
|
@@ -123,8 +131,33 @@ export const uploadBinary = async (config) => {
|
|
|
123
131
|
return binaryId;
|
|
124
132
|
}
|
|
125
133
|
}
|
|
134
|
+
// Encrypt after the dedup check, so the SHA sent at finalise, and both
|
|
135
|
+
// uploaders, operate on ciphertext (binaries.sha = ciphertext hash, #1138).
|
|
136
|
+
if (encrypt) {
|
|
137
|
+
const encrypted = await encryptUploadSource(source, apiUrl, debug);
|
|
138
|
+
enc = encrypted.enc;
|
|
139
|
+
encCleanupDir = encrypted.cleanupDir;
|
|
140
|
+
if (log) {
|
|
141
|
+
ux.info(colors.dim(`Encrypting binary before upload (KEK v${enc.kek})`));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// Re-hash once encrypted: what lands in storage is the ciphertext, and every
|
|
145
|
+
// downstream verifySha hashes the bytes it actually holds (no DEK required).
|
|
146
|
+
const sha = encrypt ? await calculateFileHash(source, debug, false) : shaPlain;
|
|
126
147
|
// Perform the upload
|
|
127
|
-
const uploadId = await performUpload({
|
|
148
|
+
const uploadId = await performUpload({
|
|
149
|
+
auth,
|
|
150
|
+
apiUrl,
|
|
151
|
+
debug,
|
|
152
|
+
enc,
|
|
153
|
+
filePath,
|
|
154
|
+
sha,
|
|
155
|
+
// Only encrypted uploads record a plaintext hash; it is what makes the
|
|
156
|
+
// next encrypted run of this binary dedupable.
|
|
157
|
+
shaPlain: encrypt ? shaPlain : undefined,
|
|
158
|
+
source,
|
|
159
|
+
startTime,
|
|
160
|
+
});
|
|
128
161
|
if (log) {
|
|
129
162
|
ux.action.stop(colors.success('\n✓ Binary uploaded with ID: ') + formatId(uploadId));
|
|
130
163
|
}
|
|
@@ -150,8 +183,41 @@ export const uploadBinary = async (config) => {
|
|
|
150
183
|
if (source?.cleanupDir) {
|
|
151
184
|
await rm(source.cleanupDir, { recursive: true, force: true }).catch(() => { });
|
|
152
185
|
}
|
|
186
|
+
if (encCleanupDir) {
|
|
187
|
+
await rm(encCleanupDir, { recursive: true, force: true }).catch(() => { });
|
|
188
|
+
}
|
|
153
189
|
}
|
|
154
190
|
};
|
|
191
|
+
/**
|
|
192
|
+
* Encrypt the prepared upload source in place (dcd#1138): generate a per-upload
|
|
193
|
+
* DEK, stream-encrypt `source.diskPath` into a temp ciphertext file, wrap the
|
|
194
|
+
* DEK with the environment's pinned KEK public key, and repoint `source` at the
|
|
195
|
+
* ciphertext so the SHA, dedup check, and both uploaders operate on ciphertext.
|
|
196
|
+
* Returns the envelope metadata (for `binaries.metadata.enc`) plus the temp dir
|
|
197
|
+
* to clean up. Throws if no KEK is available for the environment.
|
|
198
|
+
*/
|
|
199
|
+
async function encryptUploadSource(source, apiUrl, debug) {
|
|
200
|
+
const kek = resolveKekPublicKey(apiUrl);
|
|
201
|
+
if (!kek) {
|
|
202
|
+
throw new Error('Binary encryption was requested but no KEK public key is configured for this environment. ' +
|
|
203
|
+
'Set DCD_BINARY_KEK_PUBLIC=<version>:<base64> or pin one in src/config/environments.ts.');
|
|
204
|
+
}
|
|
205
|
+
const dek = generateDek();
|
|
206
|
+
const enc = wrapDek(dek, kek);
|
|
207
|
+
const cleanupDir = await mkdtemp(path.join(os.tmpdir(), 'dcd-enc-'));
|
|
208
|
+
const cipherPath = path.join(cleanupDir, 'binary.enc');
|
|
209
|
+
if (debug) {
|
|
210
|
+
console.log(`[DEBUG] Encrypting binary with KEK v${kek.version} -> ${cipherPath}`);
|
|
211
|
+
}
|
|
212
|
+
await encryptFileToPath(source.diskPath, cipherPath, dek, kek.version);
|
|
213
|
+
const { size } = await stat(cipherPath);
|
|
214
|
+
source.diskPath = cipherPath;
|
|
215
|
+
source.size = size;
|
|
216
|
+
if (debug) {
|
|
217
|
+
console.log(`[DEBUG] Ciphertext size: ${(size / 1024 / 1024).toFixed(2)} MB`);
|
|
218
|
+
}
|
|
219
|
+
return { enc, cleanupDir };
|
|
220
|
+
}
|
|
155
221
|
/**
|
|
156
222
|
* Prepares a file for upload: .app directories are zipped to a temp file on
|
|
157
223
|
* disk; everything else is described in place. Nothing is read into memory.
|
|
@@ -256,21 +322,34 @@ async function calculateFileHash(source, debug, log) {
|
|
|
256
322
|
}
|
|
257
323
|
}
|
|
258
324
|
/**
|
|
259
|
-
* Checks
|
|
325
|
+
* Checks whether a matching binary has already been uploaded.
|
|
326
|
+
*
|
|
327
|
+
* `lookup` is `{ sha }` for a plaintext upload, or `{ shaPlain, encrypted: true }`
|
|
328
|
+
* for an encrypted one (dcd#1168) — see {@link ApiGateway.checkForExistingUpload}.
|
|
329
|
+
*
|
|
330
|
+
* When asking as an encrypting client, a hit is only honoured if the server
|
|
331
|
+
* confirms the matched binary is itself encrypted. Any binary is a *plausible*
|
|
332
|
+
* match on plaintext hash, including a previously-uploaded plaintext copy of the
|
|
333
|
+
* same app, and reusing that would hand back an unencrypted binary while the user
|
|
334
|
+
* had asked for encryption. The server applies the same predicate; this is the
|
|
335
|
+
* client refusing to depend on that, so an older or misbehaving deployment
|
|
336
|
+
* degrades into a redundant upload rather than a silent loss of encryption.
|
|
337
|
+
*
|
|
260
338
|
* @param apiUrl API base URL
|
|
261
339
|
* @param auth AuthContext carrying request headers
|
|
262
|
-
* @param
|
|
340
|
+
* @param lookup Dedup key — plaintext hash for encrypted uploads, else the sha
|
|
263
341
|
* @param debug Whether debug logging is enabled
|
|
264
342
|
* @returns Promise resolving to object with exists flag and optional binaryId
|
|
265
343
|
*/
|
|
266
|
-
async function checkExistingUpload(apiUrl, auth,
|
|
344
|
+
async function checkExistingUpload(apiUrl, auth, lookup, debug) {
|
|
267
345
|
try {
|
|
268
346
|
if (debug) {
|
|
269
347
|
console.log('[DEBUG] Checking for existing upload with matching SHA...');
|
|
348
|
+
console.log(`[DEBUG] Lookup: ${JSON.stringify(lookup)}`);
|
|
270
349
|
console.log(`[DEBUG] Target endpoint: ${apiUrl}/uploads/checkForExistingUpload`);
|
|
271
350
|
}
|
|
272
351
|
const shaCheckStartTime = Date.now();
|
|
273
|
-
const { appBinaryId, exists } = await ApiGateway.checkForExistingUpload(apiUrl, auth,
|
|
352
|
+
const { appBinaryId, encrypted, exists } = await ApiGateway.checkForExistingUpload(apiUrl, auth, lookup);
|
|
274
353
|
if (debug) {
|
|
275
354
|
console.log(`[DEBUG] SHA check completed in ${Date.now() - shaCheckStartTime}ms`);
|
|
276
355
|
console.log(`[DEBUG] Existing binary found: ${exists}`);
|
|
@@ -278,6 +357,12 @@ async function checkExistingUpload(apiUrl, auth, sha, debug) {
|
|
|
278
357
|
console.log(`[DEBUG] Existing binary ID: ${appBinaryId}`);
|
|
279
358
|
}
|
|
280
359
|
}
|
|
360
|
+
if (exists && lookup.encrypted && encrypted !== true) {
|
|
361
|
+
if (debug) {
|
|
362
|
+
console.log('[DEBUG] Ignoring dedup hit: encryption was requested but the matched binary is not encrypted');
|
|
363
|
+
}
|
|
364
|
+
return { exists: false };
|
|
365
|
+
}
|
|
281
366
|
return { binaryId: appBinaryId, exists };
|
|
282
367
|
}
|
|
283
368
|
catch (error) {
|
|
@@ -504,11 +589,15 @@ function validateUploadResults(supabaseSuccess, backblazeSuccess, lastError, b2,
|
|
|
504
589
|
* @returns Promise resolving to upload ID
|
|
505
590
|
*/
|
|
506
591
|
async function performUpload(config) {
|
|
507
|
-
const { filePath, apiUrl, auth, source, sha, debug, startTime } = config;
|
|
592
|
+
const { filePath, apiUrl, auth, enc, source, sha, shaPlain, debug, startTime } = config;
|
|
508
593
|
// Request upload URL and paths
|
|
509
594
|
const { id, tempPath, finalPath, b2 } = await requestUploadPaths(apiUrl, auth, filePath, source.size, debug);
|
|
510
|
-
// Extract app metadata
|
|
595
|
+
// Extract app metadata from the original (plaintext) file. Attach the
|
|
596
|
+
// envelope so it lands on binaries.metadata.enc (#1138).
|
|
511
597
|
const metadata = await extractBinaryMetadata(filePath, debug);
|
|
598
|
+
if (enc) {
|
|
599
|
+
metadata.enc = enc;
|
|
600
|
+
}
|
|
512
601
|
const env = inferEnvFromApiUrl(apiUrl);
|
|
513
602
|
// Upload to Backblaze first (primary)
|
|
514
603
|
const backblazeResult = await handleBackblazeUpload({
|
|
@@ -554,6 +643,7 @@ async function performUpload(config) {
|
|
|
554
643
|
path: tempPath,
|
|
555
644
|
// sha is undefined when hash calculation failed — omit it explicitly
|
|
556
645
|
...(sha ? { sha } : {}),
|
|
646
|
+
...(shaPlain ? { shaPlain } : {}),
|
|
557
647
|
supabaseSuccess: supabaseResult.success,
|
|
558
648
|
});
|
|
559
649
|
if (debug) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
|
-
import { getFlowsToRunInSequence, isFlowFile, processDependencies, readDirectory, readTestYamlFileAsJson, readYamlFileAsJson, } from './execution-plan.utils.js';
|
|
3
|
+
import { getFlowsToRunInSequence, isFlowFile, isWorkspaceConfigFile, processDependencies, readDirectory, readTestYamlFileAsJson, readYamlFileAsJson, } from './execution-plan.utils.js';
|
|
4
4
|
/**
|
|
5
5
|
* Recursively check and resolve all dependencies for a flow file
|
|
6
6
|
* Includes runFlow references, JavaScript scripts, and media files
|
|
@@ -88,12 +88,14 @@ function extractDeviceCloudOverrides(config) {
|
|
|
88
88
|
/**
|
|
89
89
|
* Generate execution plan for a single flow file
|
|
90
90
|
* @param normalizedInput - Normalized path to the flow file
|
|
91
|
-
* @param
|
|
91
|
+
* @param resolvedConfigFile - Optional absolute path to a custom config file
|
|
92
92
|
* @returns Execution plan for the single file with dependencies
|
|
93
93
|
*/
|
|
94
|
-
async function planSingleFile(normalizedInput,
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
async function planSingleFile(normalizedInput, resolvedConfigFile) {
|
|
95
|
+
const inputBasename = path.basename(normalizedInput);
|
|
96
|
+
if (inputBasename === 'config.yaml' ||
|
|
97
|
+
inputBasename === 'config.yml' ||
|
|
98
|
+
isWorkspaceConfigFile(normalizedInput)) {
|
|
97
99
|
throw new Error('If using config.yaml, pass the workspace folder path, not the config file or a custom path via --config');
|
|
98
100
|
}
|
|
99
101
|
const { config } = readTestYamlFileAsJson(normalizedInput);
|
|
@@ -104,12 +106,11 @@ async function planSingleFile(normalizedInput, configFile) {
|
|
|
104
106
|
flowOverrides[normalizedInput] = extractDeviceCloudOverrides(config);
|
|
105
107
|
}
|
|
106
108
|
let workspaceConfig;
|
|
107
|
-
if (
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
throw new Error(`Config file does not exist: ${configFilePath}`);
|
|
109
|
+
if (resolvedConfigFile) {
|
|
110
|
+
if (!fs.existsSync(resolvedConfigFile)) {
|
|
111
|
+
throw new Error(`Config file does not exist: ${resolvedConfigFile}`);
|
|
111
112
|
}
|
|
112
|
-
workspaceConfig = readYamlFileAsJson(
|
|
113
|
+
workspaceConfig = readYamlFileAsJson(resolvedConfigFile);
|
|
113
114
|
}
|
|
114
115
|
const checkedDependancies = await checkDependencies(normalizedInput);
|
|
115
116
|
return {
|
|
@@ -126,11 +127,24 @@ async function planSingleFile(normalizedInput, configFile) {
|
|
|
126
127
|
* @param workspaceConfig - Workspace configuration containing flow globs
|
|
127
128
|
* @param normalizedInput - Normalized path to the workspace directory
|
|
128
129
|
* @param unfilteredFlowFiles - List of all discovered flow files
|
|
129
|
-
* @param
|
|
130
|
+
* @param resolvedConfigFile - Optional absolute path to a custom config file
|
|
130
131
|
* @param excludeFlows - --exclude-flows patterns to re-apply to glob matches
|
|
131
132
|
* @returns Filtered list of flow file paths matching the globs
|
|
132
133
|
*/
|
|
133
|
-
async function applyFlowGlobs(workspaceConfig, normalizedInput, unfilteredFlowFiles,
|
|
134
|
+
async function applyFlowGlobs(workspaceConfig, normalizedInput, unfilteredFlowFiles, resolvedConfigFile, excludeFlows) {
|
|
135
|
+
// Both branches compare absolute paths, so `--config ./x.yml` and
|
|
136
|
+
// `--config /abs/x.yml` behave identically, and a same-named file in a
|
|
137
|
+
// sibling directory is never mistaken for the active config.
|
|
138
|
+
const activeConfig = resolvedConfigFile
|
|
139
|
+
? path.normalize(resolvedConfigFile)
|
|
140
|
+
: undefined;
|
|
141
|
+
const isExcludedConfig = (absolutePath) => {
|
|
142
|
+
const base = path.basename(absolutePath);
|
|
143
|
+
if (base === 'config.yaml' || base === 'config.yml')
|
|
144
|
+
return true;
|
|
145
|
+
return (activeConfig !== undefined &&
|
|
146
|
+
path.normalize(absolutePath) === activeConfig);
|
|
147
|
+
};
|
|
134
148
|
if (workspaceConfig.flows) {
|
|
135
149
|
const globs = workspaceConfig.flows.map((g) => g);
|
|
136
150
|
// fs.globSync lands in Node 22; the CLI's `engines.node` already requires it.
|
|
@@ -144,29 +158,17 @@ async function applyFlowGlobs(workspaceConfig, normalizedInput, unfilteredFlowFi
|
|
|
144
158
|
return false;
|
|
145
159
|
}
|
|
146
160
|
});
|
|
161
|
+
// Resolve before filtering: glob matches are relative to normalizedInput,
|
|
162
|
+
// so comparing them against the config path only ever worked when the
|
|
163
|
+
// config happened to sit at the workspace root.
|
|
147
164
|
const globbedFlowFiles = matchedFiles
|
|
148
|
-
.
|
|
149
|
-
|
|
150
|
-
return false;
|
|
151
|
-
if (configFile && file === path.basename(configFile))
|
|
152
|
-
return false;
|
|
153
|
-
if (!file.endsWith('.yaml') && !file.endsWith('.yml'))
|
|
154
|
-
return false;
|
|
155
|
-
const pathParts = file.split(path.sep);
|
|
156
|
-
for (const part of pathParts) {
|
|
157
|
-
if (part.endsWith('.app'))
|
|
158
|
-
return false;
|
|
159
|
-
}
|
|
160
|
-
return true;
|
|
161
|
-
})
|
|
162
|
-
.map((file) => path.resolve(normalizedInput, file));
|
|
165
|
+
.map((file) => path.resolve(normalizedInput, file))
|
|
166
|
+
.filter((file) => !isExcludedConfig(file) && isFlowFile(file));
|
|
163
167
|
// Re-globbing from disk bypasses the earlier --exclude-flows filter, so
|
|
164
168
|
// re-apply it here or excluded flows sneak back in via `flows:` globs.
|
|
165
169
|
return filterFlowFiles(globbedFlowFiles, excludeFlows);
|
|
166
170
|
}
|
|
167
|
-
return unfilteredFlowFiles.filter((file) => !file
|
|
168
|
-
!file.endsWith('config.yml') &&
|
|
169
|
-
(!configFile || !file.endsWith(configFile)));
|
|
171
|
+
return unfilteredFlowFiles.filter((file) => !isExcludedConfig(file));
|
|
170
172
|
}
|
|
171
173
|
/**
|
|
172
174
|
* Resolve sequential execution order from workspace config
|
|
@@ -226,11 +228,14 @@ export async function plan(options) {
|
|
|
226
228
|
const { input, includeTags = [], excludeTags = [], excludeFlows, configFile, debug = false, } = options;
|
|
227
229
|
const normalizedInput = path.normalize(input);
|
|
228
230
|
const flowMetadata = {};
|
|
231
|
+
const resolvedConfigFile = configFile
|
|
232
|
+
? path.resolve(process.cwd(), configFile)
|
|
233
|
+
: undefined;
|
|
229
234
|
if (!fs.existsSync(normalizedInput)) {
|
|
230
235
|
throw new Error(`Flow path does not exist: ${path.resolve(normalizedInput)}`);
|
|
231
236
|
}
|
|
232
237
|
if (fs.lstatSync(normalizedInput).isFile()) {
|
|
233
|
-
return planSingleFile(normalizedInput,
|
|
238
|
+
return planSingleFile(normalizedInput, resolvedConfigFile);
|
|
234
239
|
}
|
|
235
240
|
let unfilteredFlowFiles = await readDirectory(normalizedInput, isFlowFile);
|
|
236
241
|
if (unfilteredFlowFiles.length === 0) {
|
|
@@ -238,17 +243,30 @@ export async function plan(options) {
|
|
|
238
243
|
}
|
|
239
244
|
unfilteredFlowFiles = filterFlowFiles(unfilteredFlowFiles, excludeFlows);
|
|
240
245
|
let workspaceConfig;
|
|
241
|
-
if (
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
throw new Error(`Config file does not exist: ${configFilePath}`);
|
|
246
|
+
if (resolvedConfigFile) {
|
|
247
|
+
if (!fs.existsSync(resolvedConfigFile)) {
|
|
248
|
+
throw new Error(`Config file does not exist: ${resolvedConfigFile}`);
|
|
245
249
|
}
|
|
246
|
-
workspaceConfig = readYamlFileAsJson(
|
|
250
|
+
workspaceConfig = readYamlFileAsJson(resolvedConfigFile);
|
|
247
251
|
}
|
|
248
252
|
else {
|
|
249
253
|
workspaceConfig = getWorkspaceConfig(normalizedInput, unfilteredFlowFiles);
|
|
250
254
|
}
|
|
251
|
-
unfilteredFlowFiles = await applyFlowGlobs(workspaceConfig, normalizedInput, unfilteredFlowFiles,
|
|
255
|
+
unfilteredFlowFiles = await applyFlowGlobs(workspaceConfig, normalizedInput, unfilteredFlowFiles, resolvedConfigFile, excludeFlows);
|
|
256
|
+
// The exclusions above are filename-based: they only catch
|
|
257
|
+
// `config.yaml`/`config.yml` and the active --config target. Any other
|
|
258
|
+
// workspace config sharing the folder (a second CI workflow's, say) is still
|
|
259
|
+
// on the list and would be parsed as a flow, so drop config-shaped files by
|
|
260
|
+
// shape — see dcd-cli#99.
|
|
261
|
+
const configShapedFiles = new Set(unfilteredFlowFiles.filter((file) => isWorkspaceConfigFile(file)));
|
|
262
|
+
if (configShapedFiles.size > 0) {
|
|
263
|
+
if (debug) {
|
|
264
|
+
console.log(`[DEBUG] Skipping ${configShapedFiles.size} workspace config file(s): ${[
|
|
265
|
+
...configShapedFiles,
|
|
266
|
+
].join(', ')}`);
|
|
267
|
+
}
|
|
268
|
+
unfilteredFlowFiles = unfilteredFlowFiles.filter((file) => !configShapedFiles.has(file));
|
|
269
|
+
}
|
|
252
270
|
if (unfilteredFlowFiles.length === 0) {
|
|
253
271
|
const error = workspaceConfig.flows
|
|
254
272
|
? new Error(`Flow inclusion pattern(s) did not match any Flow files:\n${workspaceConfig.flows.join('\n')}`)
|
|
@@ -2,6 +2,21 @@ export declare function getFlowsToRunInSequence(paths: {
|
|
|
2
2
|
[key: string]: string;
|
|
3
3
|
}, flowOrder: string[], debug?: boolean): string[];
|
|
4
4
|
export declare function isFlowFile(filePath: string): boolean;
|
|
5
|
+
/**
|
|
6
|
+
* True when a YAML file is a workspace config rather than a runnable flow.
|
|
7
|
+
*
|
|
8
|
+
* A flow is either `front matter --- steps` or a bare steps array; a
|
|
9
|
+
* single-document top-level map carrying workspace-config keys is neither, and
|
|
10
|
+
* left in the flow list it blows up processDependencies with "Expected an array
|
|
11
|
+
* of steps". Detection is by shape, not filename, so several named configs can
|
|
12
|
+
* coexist in one folder (dcd-cli#99). Requiring a recognised config key — not
|
|
13
|
+
* just "single document, top-level map" — keeps a flow that is merely *missing*
|
|
14
|
+
* its `---` separator loud rather than silently dropped.
|
|
15
|
+
*
|
|
16
|
+
* @param filePath - Path to the YAML file to classify
|
|
17
|
+
* @returns Whether the file is a workspace config rather than a flow
|
|
18
|
+
*/
|
|
19
|
+
export declare function isWorkspaceConfigFile(filePath: string): boolean;
|
|
5
20
|
export declare const readYamlFileAsJson: (filePath: string) => unknown;
|
|
6
21
|
export declare const readTestYamlFileAsJson: (filePath: string) => {
|
|
7
22
|
config: Record<string, unknown>;
|
|
@@ -38,6 +38,53 @@ export function isFlowFile(filePath) {
|
|
|
38
38
|
}
|
|
39
39
|
return filePath.endsWith('.yaml') || filePath.endsWith('.yml');
|
|
40
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Top-level keys that only ever appear in a workspace config (see
|
|
43
|
+
* IWorkspaceConfig in execution-plan.service.ts). Deliberately excludes keys
|
|
44
|
+
* Maestro also allows in flow front matter — appId, name, tags, env,
|
|
45
|
+
* onFlowStart, onFlowComplete, jsEngine.
|
|
46
|
+
*/
|
|
47
|
+
const WORKSPACE_CONFIG_KEYS = new Set([
|
|
48
|
+
'excludeTags',
|
|
49
|
+
'executionOrder',
|
|
50
|
+
'flows',
|
|
51
|
+
'includeTags',
|
|
52
|
+
'local',
|
|
53
|
+
'notifications',
|
|
54
|
+
'platform',
|
|
55
|
+
]);
|
|
56
|
+
/**
|
|
57
|
+
* True when a YAML file is a workspace config rather than a runnable flow.
|
|
58
|
+
*
|
|
59
|
+
* A flow is either `front matter --- steps` or a bare steps array; a
|
|
60
|
+
* single-document top-level map carrying workspace-config keys is neither, and
|
|
61
|
+
* left in the flow list it blows up processDependencies with "Expected an array
|
|
62
|
+
* of steps". Detection is by shape, not filename, so several named configs can
|
|
63
|
+
* coexist in one folder (dcd-cli#99). Requiring a recognised config key — not
|
|
64
|
+
* just "single document, top-level map" — keeps a flow that is merely *missing*
|
|
65
|
+
* its `---` separator loud rather than silently dropped.
|
|
66
|
+
*
|
|
67
|
+
* @param filePath - Path to the YAML file to classify
|
|
68
|
+
* @returns Whether the file is a workspace config rather than a flow
|
|
69
|
+
*/
|
|
70
|
+
export function isWorkspaceConfigFile(filePath) {
|
|
71
|
+
let parsed;
|
|
72
|
+
try {
|
|
73
|
+
parsed = readTestYamlFileAsJson(filePath);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// Unparseable — leave it in the flow list so the existing error path reports it.
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
const { config, testSteps } = parsed;
|
|
80
|
+
if (config !== null)
|
|
81
|
+
return false; // has `---` front matter → flow
|
|
82
|
+
if (Array.isArray(testSteps))
|
|
83
|
+
return false; // bare steps array → flow
|
|
84
|
+
if (!testSteps || typeof testSteps !== 'object')
|
|
85
|
+
return false;
|
|
86
|
+
return Object.keys(testSteps).some((key) => WORKSPACE_CONFIG_KEYS.has(key));
|
|
87
|
+
}
|
|
41
88
|
export const readYamlFileAsJson = (filePath) => {
|
|
42
89
|
try {
|
|
43
90
|
const normalizedPath = path.normalize(filePath);
|
|
@@ -4,6 +4,7 @@ export interface TestSubmissionConfig {
|
|
|
4
4
|
androidApiLevel?: string;
|
|
5
5
|
androidDevice?: string;
|
|
6
6
|
androidNoSnapshot?: boolean;
|
|
7
|
+
apiUrl?: string;
|
|
7
8
|
appBinaryId: string;
|
|
8
9
|
cliVersion: string;
|
|
9
10
|
commonRoot: string;
|
|
@@ -12,6 +13,12 @@ export interface TestSubmissionConfig {
|
|
|
12
13
|
deviceLocale?: string;
|
|
13
14
|
deviceMatrix?: DeviceMatrixConfig[];
|
|
14
15
|
disableAnimations?: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Encrypt the flow zip and env vars before upload (#1151/#1152), each with its
|
|
18
|
+
* own per-upload DEK wrapped under the environment KEK. Requires `apiUrl` to
|
|
19
|
+
* resolve the pinned KEK public key.
|
|
20
|
+
*/
|
|
21
|
+
encrypt?: boolean;
|
|
15
22
|
env?: string[];
|
|
16
23
|
executionPlan: IExecutionPlan;
|
|
17
24
|
flowFile: string;
|
|
@@ -22,8 +29,6 @@ export interface TestSubmissionConfig {
|
|
|
22
29
|
maestroChromeOnboarding?: boolean;
|
|
23
30
|
maestroVersion: string;
|
|
24
31
|
metadata?: string[];
|
|
25
|
-
mitmHost?: string;
|
|
26
|
-
mitmPath?: string;
|
|
27
32
|
name?: string;
|
|
28
33
|
orientation?: string;
|
|
29
34
|
raw?: unknown;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { compressFilesFromRelativePath } from '../methods.js';
|
|
4
|
+
import { encryptEnv, encryptFlowBuffer, resolveKekPublicKey, } from '../utils/envelope.js';
|
|
4
5
|
import { toPortableRelativePath } from '../utils/paths.js';
|
|
5
6
|
const mimeTypeLookupByExtension = {
|
|
6
7
|
zip: 'application/zip',
|
|
@@ -19,7 +20,7 @@ export class TestSubmissionService {
|
|
|
19
20
|
* @returns The flow zip buffer, its SHA-256, and the string-encoded fields
|
|
20
21
|
*/
|
|
21
22
|
async buildTestPayload(config) {
|
|
22
|
-
const { appBinaryId, flowFile, executionPlan, commonRoot, cliVersion, env = [], metadata = [], googlePlay = false, androidApiLevel, androidDevice, androidNoSnapshot, iOSVersion, iOSDevice, name, runnerType, maestroVersion, deviceLocale, orientation,
|
|
23
|
+
const { apiUrl, appBinaryId, encrypt = false, flowFile, executionPlan, commonRoot, cliVersion, env = [], metadata = [], googlePlay = false, androidApiLevel, androidDevice, androidNoSnapshot, iOSVersion, iOSDevice, name, runnerType, maestroVersion, deviceLocale, orientation, retry, continueOnFailure = true, report, showCrosshairs, maestroChromeOnboarding, raw, disableAnimations, deviceMatrix, debug = false, logger, } = config;
|
|
23
24
|
const { allExcludeTags, allIncludeTags, flowMetadata, flowOverrides, flowsToRun: testFileNames, referencedFiles, sequence, workspaceConfig, } = executionPlan;
|
|
24
25
|
const { flows: sequentialFlows = [] } = sequence ?? {};
|
|
25
26
|
const envObject = this.parseKeyValuePairs(env);
|
|
@@ -42,7 +43,7 @@ export class TestSubmissionService {
|
|
|
42
43
|
}
|
|
43
44
|
}
|
|
44
45
|
this.logDebug(debug, logger, `[DEBUG] Compressing files from path: ${flowFile}`);
|
|
45
|
-
const
|
|
46
|
+
const plaintextZip = await compressFilesFromRelativePath(flowFile?.endsWith('.yaml') || flowFile?.endsWith('.yml')
|
|
46
47
|
? path.dirname(flowFile)
|
|
47
48
|
: flowFile, [
|
|
48
49
|
...new Set([
|
|
@@ -51,8 +52,37 @@ export class TestSubmissionService {
|
|
|
51
52
|
...sequentialFlows,
|
|
52
53
|
]),
|
|
53
54
|
], commonRoot);
|
|
54
|
-
this.logDebug(debug, logger, `[DEBUG] Compressed file size: ${
|
|
55
|
-
//
|
|
55
|
+
this.logDebug(debug, logger, `[DEBUG] Compressed file size: ${plaintextZip.length} bytes`);
|
|
56
|
+
// Client-side envelope encryption (#1151 flow zip, #1152 env vars). Each
|
|
57
|
+
// gets its own per-upload DEK wrapped under the environment KEK; the flow
|
|
58
|
+
// zip becomes a DCDE container and `sha` is the CIPHERTEXT hash (uploads.sha
|
|
59
|
+
// stays a ciphertext hash, mirroring binaries.sha). `enc` rides `fields`
|
|
60
|
+
// as a JSON string like every other field, so both the JSON submitFlowTest
|
|
61
|
+
// body and the legacy multipart form carry it identically.
|
|
62
|
+
let buffer = plaintextZip;
|
|
63
|
+
let envObjectToSend = envObject;
|
|
64
|
+
let flowEnc;
|
|
65
|
+
if (encrypt) {
|
|
66
|
+
if (!apiUrl) {
|
|
67
|
+
throw new Error('Encryption requires apiUrl to resolve the KEK public key');
|
|
68
|
+
}
|
|
69
|
+
const kek = resolveKekPublicKey(apiUrl);
|
|
70
|
+
if (!kek) {
|
|
71
|
+
throw new Error('Encryption was requested but no KEK public key is configured for this environment. ' +
|
|
72
|
+
'Set DCD_BINARY_KEK_PUBLIC=<version>:<base64> or pin one in src/config/environments.ts.');
|
|
73
|
+
}
|
|
74
|
+
const flow = encryptFlowBuffer(plaintextZip, kek);
|
|
75
|
+
buffer = flow.ciphertext;
|
|
76
|
+
flowEnc = flow.enc;
|
|
77
|
+
this.logDebug(debug, logger, `[DEBUG] Encrypting flow zip before upload (KEK v${kek.version}); ciphertext ${buffer.length} bytes`);
|
|
78
|
+
// Only encrypt when there are env vars; an empty map has no secret to
|
|
79
|
+
// protect and stays a plaintext `{}` (no `enc` marker, passes through).
|
|
80
|
+
if (Object.keys(envObject).length > 0) {
|
|
81
|
+
envObjectToSend = { enc: encryptEnv(envObject, kek) };
|
|
82
|
+
this.logDebug(debug, logger, `[DEBUG] Encrypting ${Object.keys(envObject).length} env var(s)`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// SHA-256 of what actually gets uploaded (ciphertext when encrypted).
|
|
56
86
|
const sha = createHash('sha256').update(buffer).digest('hex');
|
|
57
87
|
this.logDebug(debug, logger, `[DEBUG] Flow ZIP SHA-256: ${sha}`);
|
|
58
88
|
// String-encoded fields, in the same order and with the same encoding as
|
|
@@ -64,7 +94,12 @@ export class TestSubmissionService {
|
|
|
64
94
|
fields.flowMetadata = JSON.stringify(this.normalizePathMap(flowMetadata, commonRoot));
|
|
65
95
|
fields.testFileOverrides = JSON.stringify(this.normalizePathMap(flowOverrides, commonRoot));
|
|
66
96
|
fields.sequentialFlows = JSON.stringify(this.normalizePaths(sequentialFlows, commonRoot));
|
|
67
|
-
fields.env = JSON.stringify(
|
|
97
|
+
fields.env = JSON.stringify(envObjectToSend);
|
|
98
|
+
// Flow-zip envelope for uploads.metadata.enc (#1151). JSON string so both
|
|
99
|
+
// submission paths carry it like every other field; the API parses it.
|
|
100
|
+
if (flowEnc) {
|
|
101
|
+
fields.enc = JSON.stringify(flowEnc);
|
|
102
|
+
}
|
|
68
103
|
// Note: googlePlay is now included in configPayload below instead of as a separate field
|
|
69
104
|
// to work around a FormData parsing issue in the API
|
|
70
105
|
// Explicit device matrix (one upload, N cells). Only sent when present, so
|
|
@@ -94,8 +129,6 @@ export class TestSubmissionService {
|
|
|
94
129
|
deviceLocale,
|
|
95
130
|
googlePlay,
|
|
96
131
|
maestroVersion,
|
|
97
|
-
mitmHost,
|
|
98
|
-
mitmPath,
|
|
99
132
|
orientation,
|
|
100
133
|
raw: JSON.stringify(raw),
|
|
101
134
|
report,
|
|
@@ -35,6 +35,7 @@ export var EAndroidApiLevels;
|
|
|
35
35
|
EAndroidApiLevels["thirtyFive"] = "35";
|
|
36
36
|
EAndroidApiLevels["thirtyFour"] = "34";
|
|
37
37
|
EAndroidApiLevels["thirtyOne"] = "31";
|
|
38
|
+
EAndroidApiLevels["thirtySeven"] = "37";
|
|
38
39
|
EAndroidApiLevels["thirtySix"] = "36";
|
|
39
40
|
EAndroidApiLevels["thirtyThree"] = "33";
|
|
40
41
|
EAndroidApiLevels["thirtyTwo"] = "32";
|