Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
- Fixed the Functions emulator dropping static properties (such as `admin.firestore.Timestamp`) of `firebase-admin` namespaces, and a crash while logging thrown values that contain circular references.
- Improved Auth emulator startup time by compiling validation schemas lazily. (#11176)
- Updated the Firebase SQL Connect local toolkit to v3.4.26, which includes the following changes:
- Internal improvements and bug fixes.
Expand Down
38 changes: 38 additions & 0 deletions src/emulator/functionsEmulatorRuntime.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { expect } from "chai";

import { Proxied } from "./functionsEmulatorRuntime";

describe("Proxied.getOriginal", () => {
it("keeps own static properties of bound functions", () => {
// Mirrors firebase-admin 13, where `admin.firestore` is an arrow function
// (no prototype) with members attached to it.
const firestore: any = () => "instance";
firestore.Timestamp = class Timestamp {};
firestore.FieldValue = { serverTimestamp: () => "ts" };
Comment on lines +9 to +11

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To adhere to the repository style guide which states 'Never use any or unknown as an escape hatch', we can avoid using any here by using Object.assign to combine the function and its static properties. This allows TypeScript to infer the correct intersection type automatically.

Suggested change
const firestore: any = () => "instance";
firestore.Timestamp = class Timestamp {};
firestore.FieldValue = { serverTimestamp: () => "ts" };
const firestore = Object.assign(() => "instance", {
Timestamp: class Timestamp {},
FieldValue: { serverTimestamp: () => "ts" },
});
References
  1. Never use any or unknown as an escape hatch. Define proper interfaces/types or use type guards. (link)

Object.defineProperty(firestore, "lazy", { get: () => 42, enumerable: true });

const bound = Proxied.getOriginal({ firestore }, "firestore");

expect(bound()).to.equal("instance");
expect(bound.Timestamp).to.equal(firestore.Timestamp);
expect(bound.FieldValue.serverTimestamp()).to.equal("ts");
expect(bound.lazy).to.equal(42);
});

it("still binds methods to the target", () => {
const target = {
value: 7,
get() {
return this.value;
},
};
expect(Proxied.getOriginal(target, "get")()).to.equal(7);
});

it("returns constructors and non-functions untouched", () => {
class Foo {}
expect(Proxied.getOriginal({ Foo }, "Foo")).to.equal(Foo);
expect(Proxied.getOriginal({ a: 1 }, "a")).to.equal(1);
expect(Proxied.getOriginal({}, "missing")).to.equal(undefined);
});
});
24 changes: 22 additions & 2 deletions src/emulator/functionsEmulatorRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ interface ProxyTarget extends Object {
obj.value === 1;
obj.incremented === 2;
*/
class Proxied<T extends ProxyTarget> {
export class Proxied<T extends ProxyTarget> {
/**
* Gets a property from the original object.
*/
Expand All @@ -107,10 +107,30 @@ class Proxied<T extends ProxyTarget> {
} else if (Proxied.isConstructor(value) || typeof value !== "function") {
return value;
} else {
return value.bind(target);
return Proxied.bindKeepingStatics(value, target);
}
}

/**
* Binds a function to a target while preserving the function's own static
* properties. Function.prototype.bind drops them, which breaks namespaces
* that are functions with attached members (e.g. firebase-admin's
* `admin.firestore.Timestamp`).
*/
private static bindKeepingStatics(fn: (...args: any[]) => any, target: any): any {
const bound = fn.bind(target);
for (const key of Reflect.ownKeys(fn)) {
if (key === "length" || key === "name" || key === "prototype") {
continue;
}
Comment on lines +123 to +125

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In strict mode, accessing or defining the "arguments" or "caller" properties on functions (especially bound functions, which are exotic objects) can throw a TypeError in some JavaScript engines. To ensure robustness and prevent potential runtime crashes, we should explicitly skip these properties when copying descriptors.

      if (
        key === "length" ||
        key === "name" ||
        key === "prototype" ||
        key === "arguments" ||
        key === "caller"
      ) {
        continue;
      }

const desc = Object.getOwnPropertyDescriptor(fn, key);
if (desc) {
Object.defineProperty(bound, key, desc);
}
}
return bound;
}

/**
* Run the original target.
*/
Expand Down
12 changes: 11 additions & 1 deletion src/error.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect } from "chai";
import { FirebaseError } from "./error";
import { FirebaseError, getErrMsg, getErrStack } from "./error";

describe("error", () => {
describe("FirebaseError", () => {
Expand Down Expand Up @@ -38,4 +38,14 @@ describe("error", () => {
expect(error).to.deep.include(allOptions);
});
});

describe("getErrMsg", () => {
it("does not throw on a circular non-Error value", () => {
const circular: Record<string, unknown> = { msg: "real message" };
circular.self = circular;

expect(getErrMsg(circular)).to.contain("real message");
expect(getErrStack(circular)).to.contain("real message");
});
});
});
8 changes: 7 additions & 1 deletion src/error.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { inspect } from "util";
import { defaultTo } from "lodash";

interface FirebaseErrorOptions {
Expand Down Expand Up @@ -47,7 +48,12 @@ export function getErrMsg(err: unknown, defaultMsg?: string): string {
} else if (defaultMsg) {
return defaultMsg;
}
return JSON.stringify(err);
try {
return JSON.stringify(err);
} catch {
Comment on lines +51 to +53

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

JSON.stringify(err) can return undefined (not the string "undefined") if err is undefined, a function, or a symbol. Since getErrMsg is typed to return a string, returning undefined violates the type signature and can cause runtime errors in callers expecting a string. Using the nullish coalescing operator with String(err) ensures a string is always returned.

Suggested change
try {
return JSON.stringify(err);
} catch {
try {
return JSON.stringify(err) ?? String(err);
} catch {

// e.g. a circular structure; fall back to something that cannot throw.
return inspect(err);
}
}

/**
Expand Down
Loading