@fireemu/linux-x64 0.7.1 → 0.9.0
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/README.md +1 -1
- package/bin/fireemu +0 -0
- package/bin/runner-node/blocking-error.mjs +27 -12
- package/bin/runner-node/blocking-event.mjs +224 -0
- package/bin/runner-node/blocking-response.mjs +99 -20
- package/bin/runner-node/diagnostic-output.mjs +259 -0
- package/bin/runner-node/discovery.mjs +135 -0
- package/bin/runner-node/http-admission.mjs +145 -0
- package/bin/runner-node/http-lifecycle.mjs +44 -0
- package/bin/runner-node/index.mjs +692 -242
- package/bin/runner-node/invocation-error.mjs +29 -0
- package/bin/runner-node/output.mjs +204 -0
- package/bin/runner-node/protocol.mjs +280 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @fireemu/linux-x64
|
|
2
2
|
|
|
3
3
|
The [fireemu](https://www.npmjs.com/package/fireemu) daemon built for `linux-x64`
|
|
4
|
-
(Rust target `x86_64-unknown-linux-musl`), version 0.
|
|
4
|
+
(Rust target `x86_64-unknown-linux-musl`), version 0.9.0.
|
|
5
5
|
|
|
6
6
|
Statically linked against musl: any glibc or musl distribution, including distroless and Alpine.
|
|
7
7
|
|
package/bin/fireemu
CHANGED
|
Binary file
|
|
@@ -29,18 +29,33 @@ const safeMessage = (value) =>
|
|
|
29
29
|
!/\p{Cc}|[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u.test(value);
|
|
30
30
|
|
|
31
31
|
export function blockingFailure(error, HttpsErrors) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
32
|
+
try {
|
|
33
|
+
if (
|
|
34
|
+
!Array.isArray(HttpsErrors) ||
|
|
35
|
+
!HttpsErrors.some((HttpsError) =>
|
|
36
|
+
typeof HttpsError === "function" && error instanceof HttpsError
|
|
37
|
+
)
|
|
38
|
+
) {
|
|
39
|
+
return unavailable;
|
|
40
|
+
}
|
|
41
|
+
// Read each potentially user-defined accessor once. In particular, validating
|
|
42
|
+
// one message and returning a second getter result can leak an unsafe message.
|
|
43
|
+
const rawCode = error.code;
|
|
44
|
+
const metadata = error.httpErrorCode;
|
|
45
|
+
const message = error.message;
|
|
46
|
+
const code = typeof rawCode === "string" ? functionErrorCodes.get(rawCode) : undefined;
|
|
47
|
+
if (
|
|
48
|
+
!code ||
|
|
49
|
+
metadata?.canonicalName !== code.canonicalName ||
|
|
50
|
+
metadata?.status !== code.status ||
|
|
51
|
+
!safeMessage(message)
|
|
52
|
+
) {
|
|
53
|
+
return unavailable;
|
|
54
|
+
}
|
|
55
|
+
return { ...code, message };
|
|
56
|
+
} catch {
|
|
57
|
+
// Error objects, proxies and even instanceof hooks can throw. Their details
|
|
58
|
+
// must not replace the fixed public fallback or break the HTTP error path.
|
|
43
59
|
return unavailable;
|
|
44
60
|
}
|
|
45
|
-
return { ...code, message: error.message };
|
|
46
61
|
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// The event a blocking function receives, built as Identity Platform's delivery builds it: the
|
|
2
|
+
// request carries an unsigned blocking token (`{"data":{"jwt":…}}`, the official Auth
|
|
3
|
+
// emulator's form), and the codebase's own firebase-functions parsers turn it into the user
|
|
4
|
+
// record and the event context, exactly as the SDK's wrapped handler does before it calls the
|
|
5
|
+
// handler (firebase-functions `common/providers/identity` `wrapHandler`). The runner keeps its
|
|
6
|
+
// own response and error handling (blocking-response.mjs, blocking-error.mjs).
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
|
|
10
|
+
/** A blocking token's payload, decoded without verification as the SDK does under the emulator. */
|
|
11
|
+
export function decodeBlockingToken(jwt) {
|
|
12
|
+
if (typeof jwt !== "string") throw new TypeError("the blocking token is not a string");
|
|
13
|
+
const parts = jwt.split(".");
|
|
14
|
+
if (parts.length !== 3) throw new TypeError("the blocking token is not a JWT");
|
|
15
|
+
let payload;
|
|
16
|
+
try {
|
|
17
|
+
payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
|
18
|
+
} catch {
|
|
19
|
+
throw new TypeError("the blocking token's payload is not JSON");
|
|
20
|
+
}
|
|
21
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
|
|
22
|
+
throw new TypeError("the blocking token's payload is not an object");
|
|
23
|
+
}
|
|
24
|
+
// The SDK's unsafeDecodeAuthBlockingToken names the subject as the uid.
|
|
25
|
+
payload.uid = payload.sub;
|
|
26
|
+
return payload;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The handler's `{user, context}` for a request that carries a blocking token, or undefined
|
|
31
|
+
* for one that does not. `parsers` are the SDK's `parseAuthUserRecord` and
|
|
32
|
+
* `parseAuthEventContext`; the user record is parsed for the user events only.
|
|
33
|
+
*/
|
|
34
|
+
export function blockingEvent(body, parsers, projectId) {
|
|
35
|
+
const jwt = body?.data?.jwt;
|
|
36
|
+
if (jwt === undefined) return undefined;
|
|
37
|
+
const decoded = decodeBlockingToken(jwt);
|
|
38
|
+
const user =
|
|
39
|
+
decoded.event_type === "beforeCreate" || decoded.event_type === "beforeSignIn"
|
|
40
|
+
? parsers.parseAuthUserRecord(decoded.user_record)
|
|
41
|
+
: undefined;
|
|
42
|
+
return { user, context: parsers.parseAuthEventContext(decoded, projectId) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function packageRoot(require) {
|
|
46
|
+
let root = dirname(require.resolve("firebase-functions"));
|
|
47
|
+
for (;;) {
|
|
48
|
+
const candidate = join(root, "package.json");
|
|
49
|
+
if (existsSync(candidate) && JSON.parse(readFileSync(candidate, "utf8")).name === "firebase-functions")
|
|
50
|
+
return root;
|
|
51
|
+
const parent = dirname(root);
|
|
52
|
+
if (parent === root) throw new Error("cannot locate the firebase-functions package root");
|
|
53
|
+
root = parent;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The SDK's parsers from the codebase's firebase-functions (`lib/common/providers/identity.js`,
|
|
59
|
+
* not in its public exports), or null when that SDK does not expose both there; the runner then
|
|
60
|
+
* uses [`portedIdentityParsers`].
|
|
61
|
+
*/
|
|
62
|
+
export function loadIdentityParsers(require) {
|
|
63
|
+
try {
|
|
64
|
+
const identity = require(join(packageRoot(require), "lib", "common", "providers", "identity.js"));
|
|
65
|
+
const { parseAuthUserRecord, parseAuthEventContext } = identity;
|
|
66
|
+
if (typeof parseAuthUserRecord !== "function" || typeof parseAuthEventContext !== "function")
|
|
67
|
+
return null;
|
|
68
|
+
return { parseAuthUserRecord, parseAuthEventContext };
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A port of firebase-functions 7.3.2 `lib/common/providers/identity.js` (MIT License, Copyright
|
|
75
|
+
// Google LLC): the event a handler receives when the codebase's firebase-functions does not
|
|
76
|
+
// expose its own parsers. Identity Platform delivers the same token to every SDK version, so
|
|
77
|
+
// the event is built as that SDK builds it rather than refused (an SDK's own parsers are used
|
|
78
|
+
// whenever they can be loaded).
|
|
79
|
+
const EVENT_MAPPING = {
|
|
80
|
+
beforeCreate: "providers/cloud.auth/eventTypes/user.beforeCreate",
|
|
81
|
+
beforeSignIn: "providers/cloud.auth/eventTypes/user.beforeSignIn",
|
|
82
|
+
beforeSendEmail: "providers/cloud.auth/eventTypes/user.beforeSendEmail",
|
|
83
|
+
beforeSendSms: "providers/cloud.auth/eventTypes/user.beforeSendSms",
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const utcDate = (value) => (value ? new Date(value).toUTCString() : null);
|
|
87
|
+
|
|
88
|
+
function parseMetadata(metadata) {
|
|
89
|
+
return {
|
|
90
|
+
creationTime: utcDate(metadata?.creation_time),
|
|
91
|
+
lastSignInTime: utcDate(metadata?.last_sign_in_time),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseProviderData(providerData) {
|
|
96
|
+
return providerData.map((provider) => ({
|
|
97
|
+
uid: provider.uid,
|
|
98
|
+
displayName: provider.display_name,
|
|
99
|
+
email: provider.email,
|
|
100
|
+
photoURL: provider.photo_url,
|
|
101
|
+
providerId: provider.provider_id,
|
|
102
|
+
phoneNumber: provider.phone_number,
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseDate(seconds) {
|
|
107
|
+
if (!seconds) return null;
|
|
108
|
+
const date = new Date(seconds * 1000);
|
|
109
|
+
return isNaN(date.getTime()) ? null : date.toUTCString();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function parseMultiFactor(multiFactor) {
|
|
113
|
+
if (!multiFactor) return null;
|
|
114
|
+
const enrolledFactors = [];
|
|
115
|
+
for (const factor of multiFactor.enrolled_factors || []) {
|
|
116
|
+
if (!factor.uid) throw new Error("INTERNAL ASSERT FAILED: Invalid multi-factor info response");
|
|
117
|
+
enrolledFactors.push({
|
|
118
|
+
uid: factor.uid,
|
|
119
|
+
factorId: factor.phone_number ? factor.factor_id || "phone" : factor.factor_id,
|
|
120
|
+
displayName: factor.display_name,
|
|
121
|
+
enrollmentTime: factor.enrollment_time ? new Date(factor.enrollment_time).toUTCString() : null,
|
|
122
|
+
phoneNumber: factor.phone_number,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return enrolledFactors.length > 0 ? { enrolledFactors } : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parseAuthUserRecord(record) {
|
|
129
|
+
if (!record?.uid) throw new Error("INTERNAL ASSERT FAILED: Invalid user response");
|
|
130
|
+
return {
|
|
131
|
+
uid: record.uid,
|
|
132
|
+
email: record.email,
|
|
133
|
+
emailVerified: record.email_verified,
|
|
134
|
+
displayName: record.display_name,
|
|
135
|
+
photoURL: record.photo_url,
|
|
136
|
+
phoneNumber: record.phone_number,
|
|
137
|
+
disabled: record.disabled || false,
|
|
138
|
+
metadata: parseMetadata(record.metadata),
|
|
139
|
+
providerData: parseProviderData(record.provider_data),
|
|
140
|
+
passwordHash: record.password_hash,
|
|
141
|
+
passwordSalt: record.password_salt,
|
|
142
|
+
customClaims: record.custom_claims,
|
|
143
|
+
tenantId: record.tenant_id,
|
|
144
|
+
tokensValidAfterTime: parseDate(record.tokens_valid_after_time),
|
|
145
|
+
multiFactor: parseMultiFactor(record.multi_factor),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function parseAdditionalUserInfo(decoded) {
|
|
150
|
+
let profile;
|
|
151
|
+
let username;
|
|
152
|
+
if (decoded.raw_user_info) {
|
|
153
|
+
try {
|
|
154
|
+
profile = JSON.parse(decoded.raw_user_info);
|
|
155
|
+
} catch {
|
|
156
|
+
// The SDK only logs an unparsable profile.
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (profile) {
|
|
160
|
+
if (decoded.sign_in_method === "github.com") username = profile.login;
|
|
161
|
+
if (decoded.sign_in_method === "twitter.com") username = profile.screen_name;
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
providerId: decoded.sign_in_method === "emailLink" ? "password" : decoded.sign_in_method,
|
|
165
|
+
profile,
|
|
166
|
+
username,
|
|
167
|
+
isNewUser: decoded.event_type === "beforeCreate",
|
|
168
|
+
recaptchaScore: decoded.recaptcha_score,
|
|
169
|
+
email: decoded.email,
|
|
170
|
+
phoneNumber: decoded.phone_number,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function parseAuthCredential(decoded, time) {
|
|
175
|
+
if (
|
|
176
|
+
!decoded.sign_in_attributes &&
|
|
177
|
+
!decoded.oauth_id_token &&
|
|
178
|
+
!decoded.oauth_access_token &&
|
|
179
|
+
!decoded.oauth_refresh_token
|
|
180
|
+
) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
claims: decoded.sign_in_attributes,
|
|
185
|
+
idToken: decoded.oauth_id_token,
|
|
186
|
+
accessToken: decoded.oauth_access_token,
|
|
187
|
+
refreshToken: decoded.oauth_refresh_token,
|
|
188
|
+
expirationTime: decoded.oauth_expires_in
|
|
189
|
+
? new Date(time + decoded.oauth_expires_in * 1000).toUTCString()
|
|
190
|
+
: undefined,
|
|
191
|
+
secret: decoded.oauth_token_secret,
|
|
192
|
+
providerId: decoded.sign_in_method === "emailLink" ? "password" : decoded.sign_in_method,
|
|
193
|
+
signInMethod: decoded.sign_in_method,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function parseAuthEventContext(decoded, projectId, time = new Date().getTime()) {
|
|
198
|
+
const eventType =
|
|
199
|
+
(EVENT_MAPPING[decoded.event_type] || decoded.event_type) +
|
|
200
|
+
(decoded.sign_in_method ? `:${decoded.sign_in_method}` : "");
|
|
201
|
+
return {
|
|
202
|
+
locale: decoded.locale,
|
|
203
|
+
ipAddress: decoded.ip_address,
|
|
204
|
+
userAgent: decoded.user_agent,
|
|
205
|
+
eventId: decoded.event_id,
|
|
206
|
+
eventType,
|
|
207
|
+
authType: decoded.user_record ? "USER" : "UNAUTHENTICATED",
|
|
208
|
+
resource: {
|
|
209
|
+
service: "identitytoolkit.googleapis.com",
|
|
210
|
+
name: decoded.tenant_id
|
|
211
|
+
? `projects/${projectId}/tenants/${decoded.tenant_id}`
|
|
212
|
+
: `projects/${projectId}`,
|
|
213
|
+
},
|
|
214
|
+
timestamp: new Date(decoded.iat * 1000).toUTCString(),
|
|
215
|
+
additionalUserInfo: parseAdditionalUserInfo(decoded),
|
|
216
|
+
credential: parseAuthCredential(decoded, time),
|
|
217
|
+
emailType: decoded.email_type,
|
|
218
|
+
smsType: decoded.sms_type,
|
|
219
|
+
params: {},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The ported parsers ([`loadIdentityParsers`] prefers the codebase's own). */
|
|
224
|
+
export const portedIdentityParsers = Object.freeze({ parseAuthUserRecord, parseAuthEventContext });
|
|
@@ -19,7 +19,10 @@ const disallowedClaims = [
|
|
|
19
19
|
const claimsMaxPayloadSize = 1000;
|
|
20
20
|
|
|
21
21
|
function validateClaims(name, claims, HttpsError) {
|
|
22
|
-
if (
|
|
22
|
+
if (claims === undefined || claims === null) return;
|
|
23
|
+
if (typeof claims !== "object" || Array.isArray(claims)) {
|
|
24
|
+
throw new HttpsError("invalid-argument", `The ${name} response must be an object or null.`);
|
|
25
|
+
}
|
|
23
26
|
const invalid = disallowedClaims.filter((claim) =>
|
|
24
27
|
Object.prototype.hasOwnProperty.call(claims, claim),
|
|
25
28
|
);
|
|
@@ -49,8 +52,9 @@ function validateBlockingResult(value, eventType, HttpsError) {
|
|
|
49
52
|
throw new HttpsError("invalid-argument", "The userRecord response must be an object.");
|
|
50
53
|
}
|
|
51
54
|
validateClaims("customClaims", authRequest.customClaims, HttpsError);
|
|
52
|
-
if (!String(eventType).includes("beforeSignIn")
|
|
55
|
+
if (!String(eventType).includes("beforeSignIn")) return;
|
|
53
56
|
validateClaims("sessionClaims", authRequest.sessionClaims, HttpsError);
|
|
57
|
+
if (authRequest.sessionClaims === undefined || authRequest.sessionClaims === null) return;
|
|
54
58
|
const combined = { ...authRequest.customClaims, ...authRequest.sessionClaims };
|
|
55
59
|
if (JSON.stringify(combined).length > claimsMaxPayloadSize) {
|
|
56
60
|
throw new HttpsError(
|
|
@@ -60,26 +64,101 @@ function validateBlockingResult(value, eventType, HttpsError) {
|
|
|
60
64
|
}
|
|
61
65
|
}
|
|
62
66
|
|
|
67
|
+
// Validate the representation that will actually cross the HTTP boundary. Callback
|
|
68
|
+
// objects can contain getters/toJSON and can remain aliased to user code. Evaluating
|
|
69
|
+
// those objects again after validation can change claims, sizes, or the update mask.
|
|
70
|
+
// JSON materialization is deliberately before claim checks; the returned value has
|
|
71
|
+
// no user getters or callable toJSON hooks and shares no objects with the callback.
|
|
72
|
+
function snapshotResponse(candidate, HttpsError) {
|
|
73
|
+
let snapshot;
|
|
74
|
+
try {
|
|
75
|
+
snapshot = JSON.parse(JSON.stringify(candidate));
|
|
76
|
+
} catch {
|
|
77
|
+
// Do not include a thrown value, circular-object path, or credential in the error.
|
|
78
|
+
throw new HttpsError("invalid-argument", "The blocking response must be JSON serializable.");
|
|
79
|
+
}
|
|
80
|
+
if (
|
|
81
|
+
!snapshot ||
|
|
82
|
+
typeof snapshot !== "object" ||
|
|
83
|
+
Array.isArray(snapshot) ||
|
|
84
|
+
!Object.prototype.hasOwnProperty.call(snapshot, "userRecord") ||
|
|
85
|
+
!snapshot.userRecord ||
|
|
86
|
+
typeof snapshot.userRecord !== "object" ||
|
|
87
|
+
Array.isArray(snapshot.userRecord)
|
|
88
|
+
) {
|
|
89
|
+
throw new HttpsError("invalid-argument", "The userRecord response must be an object.");
|
|
90
|
+
}
|
|
91
|
+
return snapshot;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function freezeResponse(snapshot) {
|
|
95
|
+
const pending = [snapshot];
|
|
96
|
+
while (pending.length > 0) {
|
|
97
|
+
const value = pending.pop();
|
|
98
|
+
if (value && typeof value === "object") {
|
|
99
|
+
for (const child of Object.values(value)) pending.push(child);
|
|
100
|
+
Object.freeze(value);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return snapshot;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The customClaims text as the function's JSON.stringify gives it: what the Functions SDK sends
|
|
107
|
+
// Identity Platform, which production reads back as `customAttributes` with its key order
|
|
108
|
+
// (AUTH-TENANT-BLOCKING recording 2026-09-28). The runner owns this member; the daemon uses the
|
|
109
|
+
// text only when it parses to the claims it applies.
|
|
110
|
+
const CUSTOM_CLAIMS_TEXT = "fireemuCustomClaimsText";
|
|
111
|
+
|
|
112
|
+
function attachCustomClaimsText(snapshot) {
|
|
113
|
+
delete snapshot[CUSTOM_CLAIMS_TEXT];
|
|
114
|
+
// validateBlockingResult has admitted only an object, null or no claims.
|
|
115
|
+
const claims = snapshot.userRecord.customClaims;
|
|
116
|
+
if (claims != null) snapshot[CUSTOM_CLAIMS_TEXT] = JSON.stringify(claims);
|
|
117
|
+
return snapshot;
|
|
118
|
+
}
|
|
119
|
+
|
|
63
120
|
export function blockingResult(value, eventType, HttpsError) {
|
|
121
|
+
// Preserve the runner's established no-result behaviour.
|
|
64
122
|
if (!value || typeof value !== "object") return {};
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
123
|
+
if (Array.isArray(value)) {
|
|
124
|
+
throw new HttpsError("invalid-argument", "The userRecord response must be an object.");
|
|
125
|
+
}
|
|
126
|
+
let candidate;
|
|
127
|
+
try {
|
|
128
|
+
if (Object.prototype.hasOwnProperty.call(value, "userRecord")) {
|
|
129
|
+
// Preserve SDK wire-envelope extensions, but never return the caller's object.
|
|
130
|
+
candidate = value;
|
|
131
|
+
} else {
|
|
132
|
+
const userRecord = {};
|
|
133
|
+
const updateMask = [];
|
|
134
|
+
// Each field goes under its public name, as the Functions SDK's generateResponsePayload
|
|
135
|
+
// sends it: Identity Platform does not apply `photoURL` (it reads `photoUrl`), so a
|
|
136
|
+
// function's photoURL is ignored (AUTH-TENANT-BLOCKING recording 2026-09-28,
|
|
137
|
+
// ordering#lookup-profile; the official Auth emulator reads `photoUrl` only too).
|
|
138
|
+
for (const name of [
|
|
139
|
+
"displayName",
|
|
140
|
+
"photoURL",
|
|
141
|
+
"disabled",
|
|
142
|
+
"emailVerified",
|
|
143
|
+
"customClaims",
|
|
144
|
+
"sessionClaims",
|
|
145
|
+
]) {
|
|
146
|
+
if (Object.prototype.hasOwnProperty.call(value, name)) {
|
|
147
|
+
const field = value[name];
|
|
148
|
+
// Like the Functions SDK's getUpdateMask, undefined means no update;
|
|
149
|
+
// null, false, and an empty string are still explicit updates.
|
|
150
|
+
if (field === undefined) continue;
|
|
151
|
+
userRecord[name] = field;
|
|
152
|
+
updateMask.push(name);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (updateMask.length === 0) return {};
|
|
156
|
+
candidate = { userRecord: { ...userRecord, updateMask: updateMask.join(",") } };
|
|
80
157
|
}
|
|
158
|
+
} catch {
|
|
159
|
+
throw new HttpsError("invalid-argument", "The blocking response could not be read.");
|
|
81
160
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
161
|
+
const snapshot = snapshotResponse(candidate, HttpsError);
|
|
162
|
+
validateBlockingResult(snapshot, eventType, HttpsError);
|
|
163
|
+
return freezeResponse(attachCustomClaimsText(snapshot));
|
|
85
164
|
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// Bounded admission for direct stderr (and redirected user stdout) writes.
|
|
2
|
+
// The native Writable owns byte ordering and corking. Callback accounting adds
|
|
3
|
+
// backpressure even when a native write completes synchronously.
|
|
4
|
+
// This is not a process-wide sandbox: direct fd writes and replaced methods bypass it.
|
|
5
|
+
import { performance } from 'node:perf_hooks';
|
|
6
|
+
import { types } from 'node:util';
|
|
7
|
+
|
|
8
|
+
const typedPrototype = Object.getPrototypeOf(Uint8Array.prototype);
|
|
9
|
+
const viewGetters = Object.fromEntries(['buffer', 'byteOffset', 'byteLength'].map(key =>
|
|
10
|
+
[key, Object.getOwnPropertyDescriptor(typedPrototype, key).get]));
|
|
11
|
+
const dataViewGetters = Object.fromEntries(['buffer', 'byteOffset', 'byteLength'].map(key =>
|
|
12
|
+
[key, Object.getOwnPropertyDescriptor(DataView.prototype, key).get]));
|
|
13
|
+
function viewData(chunk) {
|
|
14
|
+
const getters = types.isDataView(chunk) ? dataViewGetters : viewGetters;
|
|
15
|
+
return { buffer: getters.buffer.call(chunk), offset: getters.byteOffset.call(chunk),
|
|
16
|
+
length: getters.byteLength.call(chunk) };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const MAX_DIAGNOSTIC_BYTES = 8 * 1024 * 1024;
|
|
20
|
+
export const MAX_DIAGNOSTIC_WRITES = 1024;
|
|
21
|
+
export const MAX_DIAGNOSTIC_WAIT_MS = 30_000;
|
|
22
|
+
export const DIAGNOSTIC_FINISH_MS = 1_000;
|
|
23
|
+
|
|
24
|
+
export class DiagnosticOutputError extends Error {
|
|
25
|
+
constructor(reason) {
|
|
26
|
+
super(`runner diagnostic output failed (${reason})`);
|
|
27
|
+
this.name = 'DiagnosticOutputError';
|
|
28
|
+
this.code = 'ERR_FIREEMU_DIAGNOSTIC_OUTPUT';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function limit(value, maximum, name) {
|
|
33
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
|
|
34
|
+
throw new TypeError(`invalid diagnostic ${name}`);
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class DiagnosticWriter {
|
|
40
|
+
#stream;
|
|
41
|
+
#write;
|
|
42
|
+
#emit;
|
|
43
|
+
#highWaterMark;
|
|
44
|
+
#needsDrain = false;
|
|
45
|
+
#nativeBackpressure = false;
|
|
46
|
+
#drainScheduled = false;
|
|
47
|
+
#onError;
|
|
48
|
+
#maxBytes;
|
|
49
|
+
#maxWrites;
|
|
50
|
+
#waitMs;
|
|
51
|
+
#finishMs;
|
|
52
|
+
#defaultEncoding = 'utf8';
|
|
53
|
+
#pending = new Set();
|
|
54
|
+
#bytes = 0;
|
|
55
|
+
#failed = null;
|
|
56
|
+
#closing = false;
|
|
57
|
+
#closeAt = Infinity;
|
|
58
|
+
#timer = null;
|
|
59
|
+
#finishPromise;
|
|
60
|
+
#finishResolve;
|
|
61
|
+
|
|
62
|
+
constructor(stream, {
|
|
63
|
+
onError,
|
|
64
|
+
maxBytes = MAX_DIAGNOSTIC_BYTES,
|
|
65
|
+
maxWrites = MAX_DIAGNOSTIC_WRITES,
|
|
66
|
+
waitMs = MAX_DIAGNOSTIC_WAIT_MS,
|
|
67
|
+
finishMs = DIAGNOSTIC_FINISH_MS,
|
|
68
|
+
} = {}) {
|
|
69
|
+
if (typeof stream?.write !== 'function' || typeof stream?.on !== 'function' ||
|
|
70
|
+
typeof stream?.emit !== 'function' ||
|
|
71
|
+
typeof onError !== 'function') throw new TypeError('diagnostic stream and onError required');
|
|
72
|
+
this.#stream = stream;
|
|
73
|
+
this.#write = stream.write.bind(stream);
|
|
74
|
+
this.#emit = stream.emit.bind(stream);
|
|
75
|
+
this.#onError = onError;
|
|
76
|
+
this.#maxBytes = limit(maxBytes, MAX_DIAGNOSTIC_BYTES, 'bytes');
|
|
77
|
+
this.#maxWrites = limit(maxWrites, MAX_DIAGNOSTIC_WRITES, 'writes');
|
|
78
|
+
this.#waitMs = limit(waitMs, MAX_DIAGNOSTIC_WAIT_MS, 'wait');
|
|
79
|
+
this.#finishMs = limit(finishMs, DIAGNOSTIC_FINISH_MS, 'finish wait');
|
|
80
|
+
const nativeHighWaterMark = stream.writableHighWaterMark;
|
|
81
|
+
this.#highWaterMark = Math.min(this.#maxBytes,
|
|
82
|
+
Number.isSafeInteger(nativeHighWaterMark) && nativeHighWaterMark > 0
|
|
83
|
+
? nativeHighWaterMark : 64 * 1024);
|
|
84
|
+
// Native drain can fire before write callbacks. Do not tell a producer to
|
|
85
|
+
// resume while our callback reservations still fill the budget. Coalesce
|
|
86
|
+
// it with logical backpressure and emit only after BOTH have cleared.
|
|
87
|
+
stream.emit = (event, ...args) => {
|
|
88
|
+
if (event !== 'drain') return this.#emit(event, ...args);
|
|
89
|
+
this.#nativeBackpressure = false;
|
|
90
|
+
this.#requestDrain();
|
|
91
|
+
return (stream.listenerCount?.('drain') ?? 0) > 0;
|
|
92
|
+
};
|
|
93
|
+
// Retain the error listener after failure: an error event may follow its callback.
|
|
94
|
+
stream.on('error', () => this.#fail('stream error'));
|
|
95
|
+
stream.on('close', () => this.#fail('stream closed'));
|
|
96
|
+
stream.on('finish', () => this.#fail('stream ended'));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
get state() {
|
|
100
|
+
return Object.freeze({ pendingBytes: this.#bytes, pendingWrites: this.#pending.size,
|
|
101
|
+
failed: this.#failed !== null, closing: this.#closing });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
setDefaultEncoding(encoding) {
|
|
105
|
+
if (typeof encoding !== 'string' || !Buffer.isEncoding(encoding)) {
|
|
106
|
+
throw new TypeError('invalid diagnostic encoding');
|
|
107
|
+
}
|
|
108
|
+
this.#defaultEncoding = encoding;
|
|
109
|
+
return this;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
write(chunk, encoding, callback) {
|
|
113
|
+
if (typeof encoding === 'function') { callback = encoding; encoding = undefined; }
|
|
114
|
+
if (callback !== undefined && callback !== null && typeof callback !== 'function') {
|
|
115
|
+
throw new TypeError('diagnostic write callback must be a function');
|
|
116
|
+
}
|
|
117
|
+
if (this.#failed || this.#closing) {
|
|
118
|
+
if (callback) process.nextTick(callback, this.#failed ?? new DiagnosticOutputError('closing'));
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
if (this.#expired()) { this.#fail('deadline'); return this.write(chunk, encoding, callback); }
|
|
122
|
+
let size, selectedEncoding, view;
|
|
123
|
+
if (typeof chunk === 'string') {
|
|
124
|
+
selectedEncoding = encoding || this.#defaultEncoding;
|
|
125
|
+
if (typeof selectedEncoding !== 'string' || !Buffer.isEncoding(selectedEncoding)) {
|
|
126
|
+
throw new TypeError('invalid diagnostic encoding');
|
|
127
|
+
}
|
|
128
|
+
size = Buffer.byteLength(chunk, selectedEncoding);
|
|
129
|
+
} else if (ArrayBuffer.isView(chunk)) {
|
|
130
|
+
view = viewData(chunk);
|
|
131
|
+
size = view.length;
|
|
132
|
+
} else {
|
|
133
|
+
throw new TypeError('diagnostic chunk must be a string or ArrayBuffer view');
|
|
134
|
+
}
|
|
135
|
+
// Include zero-length writes in the count: callbacks also retain memory.
|
|
136
|
+
if (this.#pending.size >= this.#maxWrites || this.#bytes + size > this.#maxBytes) {
|
|
137
|
+
this.#fail('pending limit');
|
|
138
|
+
return this.write(chunk, encoding, callback);
|
|
139
|
+
}
|
|
140
|
+
if (this.#stream.destroyed || this.#stream.writableEnded) {
|
|
141
|
+
this.#fail('stream unavailable');
|
|
142
|
+
return this.write(chunk, encoding, callback);
|
|
143
|
+
}
|
|
144
|
+
// Copy views before native queuing; later caller mutation cannot change admitted bytes.
|
|
145
|
+
// Strings are immutable. Encoding once also fixes accounting for hex/base64 inputs.
|
|
146
|
+
const payload = typeof chunk === 'string' ? Buffer.from(chunk, selectedEncoding) :
|
|
147
|
+
Buffer.from(new Uint8Array(view.buffer, view.offset, view.length));
|
|
148
|
+
// Buffer.byteLength(base64/hex) can be conservative for malformed input. Charge
|
|
149
|
+
// the bytes actually queued, after rejecting oversized allocation candidates above.
|
|
150
|
+
size = payload.length;
|
|
151
|
+
const entry = { size, at: performance.now(), callback };
|
|
152
|
+
this.#pending.add(entry);
|
|
153
|
+
this.#bytes += size;
|
|
154
|
+
let accepted;
|
|
155
|
+
try {
|
|
156
|
+
accepted = this.#write(payload, error => {
|
|
157
|
+
if (!this.#pending.has(entry)) return;
|
|
158
|
+
if (error) { this.#fail('write callback'); return; }
|
|
159
|
+
if (this.#expired()) { this.#fail('deadline'); return; }
|
|
160
|
+
this.#settle(entry);
|
|
161
|
+
this.#arm();
|
|
162
|
+
});
|
|
163
|
+
} catch {
|
|
164
|
+
this.#fail('write');
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
if (accepted !== true && accepted !== false) {
|
|
168
|
+
this.#fail('write return');
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
if (accepted === false) this.#nativeBackpressure = true;
|
|
172
|
+
const pressured = accepted === false || this.#bytes >= this.#highWaterMark ||
|
|
173
|
+
this.#pending.size >= this.#maxWrites;
|
|
174
|
+
if (pressured) this.#needsDrain = true;
|
|
175
|
+
this.#arm();
|
|
176
|
+
this.#requestDrain();
|
|
177
|
+
// Native false remains false. Native true may become false while completed
|
|
178
|
+
// writes still have deferred callbacks; otherwise a cooperative producer
|
|
179
|
+
// can exceed our limits before Node gets a turn to run those callbacks.
|
|
180
|
+
// In either case the write was accepted and must not be resent.
|
|
181
|
+
return this.#failed ? false : !pressured;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
#settle(entry, error) {
|
|
185
|
+
if (!this.#pending.delete(entry)) return;
|
|
186
|
+
this.#bytes -= entry.size;
|
|
187
|
+
// User callbacks cannot reenter or corrupt admission bookkeeping. Like Writable,
|
|
188
|
+
// completion is asynchronous even with an inline write implementation in a test.
|
|
189
|
+
if (entry.callback) process.nextTick(entry.callback, error);
|
|
190
|
+
this.#requestDrain();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
#requestDrain() {
|
|
194
|
+
if (this.#failed || this.#closing || !this.#needsDrain || this.#nativeBackpressure ||
|
|
195
|
+
this.#pending.size !== 0 || this.#drainScheduled) return;
|
|
196
|
+
this.#drainScheduled = true;
|
|
197
|
+
process.nextTick(() => {
|
|
198
|
+
this.#drainScheduled = false;
|
|
199
|
+
// A user write callback can enqueue new data before this notification.
|
|
200
|
+
if (this.#failed || this.#closing || !this.#needsDrain || this.#nativeBackpressure ||
|
|
201
|
+
this.#pending.size !== 0) return;
|
|
202
|
+
this.#needsDrain = false;
|
|
203
|
+
this.#emit('drain');
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
#expired() {
|
|
208
|
+
const oldest = this.#pending.values().next().value;
|
|
209
|
+
if (!oldest) return false;
|
|
210
|
+
const now = performance.now();
|
|
211
|
+
return now >= oldest.at + this.#waitMs || now >= this.#closeAt;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
#fail(reason) {
|
|
215
|
+
if (this.#failed) return;
|
|
216
|
+
this.#failed = new DiagnosticOutputError(reason);
|
|
217
|
+
clearTimeout(this.#timer); this.#timer = null;
|
|
218
|
+
for (const entry of this.#pending) this.#settle(entry, this.#failed);
|
|
219
|
+
this.#finishResolve?.(false);
|
|
220
|
+
// Never write a diagnostic about this failure back into the failed stderr.
|
|
221
|
+
try { this.#onError(this.#failed); } catch { /* failure is already permanent */ }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
#arm() {
|
|
225
|
+
clearTimeout(this.#timer); this.#timer = null;
|
|
226
|
+
if (this.#failed) return;
|
|
227
|
+
const oldest = this.#pending.values().next().value;
|
|
228
|
+
if (!oldest) { if (this.#closing) this.#finishResolve?.(true); return; }
|
|
229
|
+
const remaining = Math.min(oldest.at + this.#waitMs, this.#closeAt) - performance.now();
|
|
230
|
+
if (remaining <= 0) { this.#fail('deadline'); return; }
|
|
231
|
+
this.#timer = setTimeout(() => this.#arm(), Math.max(1, Math.ceil(remaining)));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Flush already-admitted writes only; do not close fd2 or wait for user callbacks/tasks.
|
|
235
|
+
finish() {
|
|
236
|
+
if (this.#finishPromise) return this.#finishPromise;
|
|
237
|
+
this.#closing = true;
|
|
238
|
+
this.#closeAt = performance.now() + this.#finishMs;
|
|
239
|
+
this.#finishPromise = new Promise(resolve => { this.#finishResolve = resolve; });
|
|
240
|
+
if (this.#failed) this.#finishResolve(false); else this.#arm();
|
|
241
|
+
return this.#finishPromise;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Install before loading a user codebase. Keep the original stream object, fd,
|
|
246
|
+
// event listeners, cork/uncork and false-then-drain producer contract intact.
|
|
247
|
+
// Own pending callbacks can add backpressure; native true is not always returned.
|
|
248
|
+
export function installDiagnosticOutput(stream, options) {
|
|
249
|
+
const writer = new DiagnosticWriter(stream, options);
|
|
250
|
+
const setEncoding = stream.setDefaultEncoding?.bind(stream);
|
|
251
|
+
stream.write = writer.write.bind(writer);
|
|
252
|
+
if (setEncoding) stream.setDefaultEncoding = encoding => {
|
|
253
|
+
// Validate with the native API too; preserve its chaining return value.
|
|
254
|
+
const result = setEncoding(encoding);
|
|
255
|
+
writer.setDefaultEncoding(encoding);
|
|
256
|
+
return result;
|
|
257
|
+
};
|
|
258
|
+
return writer;
|
|
259
|
+
}
|