ses
Version:
Hardened JavaScript for Fearless Cooperation
585 lines (539 loc) • 18.1 kB
JavaScript
// Copyright (C) 2019 Agoric, under Apache License 2.0
// @ts-check
// To ensure that this module operates without special privilege, it should
// not reference the free variable `console` except for its own internal
// debugging purposes in the declaration of `internalDebugConsole`, which is
// normally commented out.
// This module however has top level mutable state which is observable to code
// given access to the `loggedErrorHandler`, such as the causal console
// of `console.js`. However, for code that does not have such access, this
// module should not be observably impure.
import {
RangeError,
TypeError,
WeakMap,
arrayJoin,
arrayMap,
arrayPop,
arrayPush,
assign,
freeze,
defineProperty,
globalThis,
is,
isError,
regexpTest,
stringIndexOf,
stringReplace,
stringSlice,
stringStartsWith,
weakmapDelete,
weakmapGet,
weakmapHas,
weakmapSet,
AggregateError,
getOwnPropertyDescriptors,
ownKeys,
create,
objectPrototype,
objectHasOwnProperty,
} from '../commons.js';
import { an, bestEffortStringify } from './stringify-utils.js';
import './types.js';
import './internal-types.js';
import { makeNoteLogArgsArrayKit } from './note-log-args.js';
/**
* @import {BaseAssert, Assert, AssertionFunctions, AssertionUtilities, StringablePayload, DetailsToken, MakeAssert} from '../../types.js'
* @import {LogArgs, NoteCallback, LoggedErrorHandler} from "./internal-types.js";
*/
// For our internal debugging purposes, uncomment
// const internalDebugConsole = console;
// /////////////////////////////////////////////////////////////////////////////
/** @type {WeakMap<StringablePayload, any>} */
const declassifiers = new WeakMap();
/** @type {AssertionUtilities['quote']} */
const quote = (payload, spaces = undefined) => {
const result = freeze({
toString: freeze(() => bestEffortStringify(payload, spaces)),
});
weakmapSet(declassifiers, result, payload);
return result;
};
freeze(quote);
const canBeBare = freeze(/^[\w:-]( ?[\w:-])*$/);
/**
* @type {AssertionUtilities['bare']}
*/
const bare = (payload, spaces = undefined) => {
if (typeof payload !== 'string' || !regexpTest(canBeBare, payload)) {
return quote(payload, spaces);
}
const result = freeze({
toString: freeze(() => payload),
});
weakmapSet(declassifiers, result, payload);
return result;
};
freeze(bare);
// /////////////////////////////////////////////////////////////////////////////
/**
* @typedef {object} HiddenDetails
*
* Captures the arguments passed to the `details` template string tag.
*
* @property {TemplateStringsArray | string[]} template
* @property {any[]} args
*/
/**
* @type {WeakMap<DetailsToken, HiddenDetails>}
*
* Maps from a details token which a `details` template literal returned
* to a record of the contents of that template literal expression.
*/
const hiddenDetailsMap = new WeakMap();
/**
* @param {HiddenDetails} hiddenDetails
* @returns {string}
*/
const getMessageString = ({ template, args }) => {
const parts = [template[0]];
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
let argStr;
if (weakmapHas(declassifiers, arg)) {
argStr = `${arg}`;
} else if (isError(arg)) {
argStr = `(${an(arg.name)})`;
} else {
argStr = `(${an(typeof arg)})`;
}
arrayPush(parts, argStr, template[i + 1]);
}
return arrayJoin(parts, '');
};
/**
* Give detailsTokens a toString behavior. To minimize the overhead of
* creating new detailsTokens, we do this with an
* inherited `this` sensitive `toString` method, even though we normally
* avoid `this` sensitivity. To protect the method from inappropriate
* `this` application, it does something interesting only for objects
* registered in `redactedDetails`, which should be exactly the detailsTokens.
*
* The printing behavior must not reveal anything redacted, so we just use
* the same `getMessageString` we use to construct the redacted message
* string for a thrown assertion error.
*/
const DetailsTokenProto = freeze({
toString() {
const hiddenDetails = weakmapGet(hiddenDetailsMap, this);
if (hiddenDetails === undefined) {
return '[Not a DetailsToken]';
}
return getMessageString(hiddenDetails);
},
});
freeze(DetailsTokenProto.toString);
/**
* Normally this is the function exported as `assert.details` and often
* spelled `X`. However, if the `{errorTaming: 'unsafe'}` or
* `{errorTaming: 'unsafe-debug'}` option is
* given to `lockdown`, then `unredactedDetails` is used instead.
*
* There are some unconditional uses of `redactedDetails` in this module. All
* of them should be uses where the template literal has no redacted
* substitution values. In those cases, the two are equivalent.
*
* @type {AssertionUtilities['details']}
*/
const redactedDetails = (template, ...args) => {
// Keep in mind that the vast majority of calls to `details` creates
// a details token that is never used, so this path must remain as fast as
// possible. Hence we store what we've got with little processing, postponing
// all the work to happen only if needed, for example, if an assertion fails.
const detailsToken = freeze({ __proto__: DetailsTokenProto });
weakmapSet(hiddenDetailsMap, detailsToken, { template, args });
return /** @type {DetailsToken} */ (/** @type {unknown} */ (detailsToken));
};
freeze(redactedDetails);
/**
* `unredactedDetails` is like `details` except that it does not redact
* anything. It acts like `details` would act if all substitution values
* were wrapped with the `quote` function above (the function normally
* spelled `q`). If the `{errorTaming: 'unsafe'}`
* or `{errorTaming: 'unsafe-debug'}` option is given to
* `lockdown`, then the lockdown-shim arranges for the global `assert` to be
* one whose `details` property is `unredactedDetails`.
* This setting optimizes the debugging and testing experience at the price
* of safety. `unredactedDetails` also sacrifices the speed of `details`,
* which is usually fine in debugging and testing.
*
* @type {AssertionUtilities['details']}
*/
const unredactedDetails = (template, ...args) => {
args = arrayMap(args, arg =>
weakmapHas(declassifiers, arg) ? arg : quote(arg),
);
return redactedDetails(template, ...args);
};
freeze(unredactedDetails);
export { unredactedDetails };
/**
* @param {HiddenDetails} hiddenDetails
* @returns {LogArgs}
*/
const getLogArgs = ({ template, args }) => {
const logArgs = [template[0]];
for (let i = 0; i < args.length; i += 1) {
let arg = args[i];
if (weakmapHas(declassifiers, arg)) {
arg = weakmapGet(declassifiers, arg);
}
// Remove the extra spaces (since console.error puts them
// between each cause).
const priorWithoutSpace = stringReplace(arrayPop(logArgs) || '', / $/, '');
if (priorWithoutSpace !== '') {
arrayPush(logArgs, priorWithoutSpace);
}
const nextWithoutSpace = stringReplace(template[i + 1], /^ /, '');
arrayPush(logArgs, arg, nextWithoutSpace);
}
if (logArgs[logArgs.length - 1] === '') {
arrayPop(logArgs);
}
return logArgs;
};
/**
* @type {WeakMap<Error, LogArgs>}
*
* Maps from an error object to the log args that are a more informative
* alternative message for that error. When logging the error, these
* log args should be preferred to `error.message`.
*/
const hiddenMessageLogArgs = new WeakMap();
// So each error tag will be unique.
let errorTagNum = 0;
/**
* @type {WeakMap<Error, string>}
*/
const errorTags = new WeakMap();
/**
* @param {Error} err
* @param {string=} optErrorName
* @returns {string}
*/
const tagError = (err, optErrorName = err.name) => {
let errorTag = weakmapGet(errorTags, err);
if (errorTag !== undefined) {
return errorTag;
}
errorTagNum += 1;
errorTag = `${optErrorName}#${errorTagNum}`;
weakmapSet(errorTags, err, errorTag);
return errorTag;
};
/**
* Make reasonable best efforts to make a `Passable` error.
* - `sanitizeError` will remove any "extraneous" own properties already added
* by the host,
* such as `fileName`,`lineNumber` on FireFox or `line` on Safari.
* - If any such "extraneous" properties were removed, `sanitizeError` will
* annotate
* the error with them, so they still appear on the causal console
* log output for diagnostic purposes, but not be otherwise visible.
* - `sanitizeError` will ensure that any expected properties already
* added by the host are data
* properties, converting accessor properties to data properties as needed,
* such as `stack` on v8 (Chrome, Brave, Edge?)
* - `sanitizeError` will freeze the error, preventing any correct engine from
* adding or
* altering any of the error's own properties `sanitizeError` is done.
*
* However, `sanitizeError` will not, for example, `harden`
* (i.e., deeply freeze)
* or ensure that the `cause` or `errors` property satisfy the `Passable`
* constraints. The purpose of `sanitizeError` is only to protect against
* mischief the host may have already added to the error as created,
* not to ensure that the error is actually Passable. For that,
* see `toPassableError` in `@endo/pass-style`.
*
* @param {Error} error
*/
export const sanitizeError = error => {
const descs = getOwnPropertyDescriptors(error);
const {
name: _nameDesc,
message: _messageDesc,
errors: _errorsDesc = undefined,
cause: _causeDesc = undefined,
stack: _stackDesc = undefined,
...restDescs
} = descs;
const restNames = ownKeys(restDescs);
if (restNames.length >= 1) {
for (const name of restNames) {
delete error[name];
}
const droppedNote = create(objectPrototype, restDescs);
// eslint-disable-next-line no-use-before-define
note(
error,
redactedDetails`originally with properties ${quote(droppedNote)}`,
);
}
for (const name of ownKeys(error)) {
// @ts-expect-error TS still confused by symbols as property names
const desc = descs[name];
if (desc && objectHasOwnProperty(desc, 'get')) {
defineProperty(error, name, {
value: error[name], // invoke the getter to convert to data property
});
}
}
freeze(error);
};
/**
* @type {AssertionUtilities['error']}
*/
const makeError = (
optDetails = redactedDetails`Assert failed`,
errConstructor = globalThis.Error,
{
errorName = undefined,
cause = undefined,
errors = undefined,
sanitize = true,
} = {},
) => {
if (typeof optDetails === 'string') {
// If it is a string, use it as the literal part of the template so
// it doesn't get quoted.
optDetails = redactedDetails([optDetails]);
}
const hiddenDetails = weakmapGet(hiddenDetailsMap, optDetails);
if (hiddenDetails === undefined) {
throw TypeError(`unrecognized details ${quote(optDetails)}`);
}
const messageString = getMessageString(hiddenDetails);
const opts = cause && { cause };
let error;
if (
typeof AggregateError !== 'undefined' &&
errConstructor === AggregateError
) {
error = AggregateError(errors || [], messageString, opts);
} else {
error = /** @type {ErrorConstructor} */ (errConstructor)(
messageString,
opts,
);
if (errors !== undefined) {
// Since we need to tolerate `errors` on an AggregateError, may as
// well tolerate it on all errors.
defineProperty(error, 'errors', {
value: errors,
writable: true,
enumerable: false,
configurable: true,
});
}
}
weakmapSet(hiddenMessageLogArgs, error, getLogArgs(hiddenDetails));
if (errorName !== undefined) {
tagError(error, errorName);
}
if (sanitize) {
sanitizeError(error);
}
// The next line is a particularly fruitful place to put a breakpoint.
return error;
};
freeze(makeError);
// /////////////////////////////////////////////////////////////////////////////
const { addLogArgs, takeLogArgsArray } = makeNoteLogArgsArrayKit();
/**
* @type {WeakMap<Error, NoteCallback[]>}
*
* An augmented console will normally only take the hidden noteArgs array once,
* when it logs the error being annotated. Once that happens, further
* annotations of that error should go to the console immediately. We arrange
* that by accepting a note-callback function from the console as an optional
* part of that taking operation. Normally there will only be at most one
* callback per error, but that depends on console behavior which we should not
* assume. We make this an array of callbacks so multiple registrations
* are independent.
*/
const hiddenNoteCallbackArrays = new WeakMap();
/** @type {AssertionUtilities['note']} */
const note = (error, detailsNote) => {
if (typeof detailsNote === 'string') {
// If it is a string, use it as the literal part of the template so
// it doesn't get quoted.
detailsNote = redactedDetails([detailsNote]);
}
const hiddenDetails = weakmapGet(hiddenDetailsMap, detailsNote);
if (hiddenDetails === undefined) {
throw TypeError(`unrecognized details ${quote(detailsNote)}`);
}
const logArgs = getLogArgs(hiddenDetails);
const callbacks = weakmapGet(hiddenNoteCallbackArrays, error);
if (callbacks !== undefined) {
for (const callback of callbacks) {
callback(error, logArgs);
}
} else {
addLogArgs(error, logArgs);
}
};
freeze(note);
/**
* The unprivileged form that just uses the de facto `error.stack` property.
* The start compartment normally has a privileged `globalThis.getStackString`
* which should be preferred if present.
*
* @param {Error} error
* @returns {string}
*/
const defaultGetStackString = error => {
if (!('stack' in error)) {
return '';
}
const stackString = `${error.stack}`;
const pos = stringIndexOf(stackString, '\n');
if (stringStartsWith(stackString, ' ') || pos === -1) {
return stackString;
}
return stringSlice(stackString, pos + 1); // exclude the initial newline
};
/** @type {LoggedErrorHandler} */
const loggedErrorHandler = {
getStackString: globalThis.getStackString || defaultGetStackString,
tagError: error => tagError(error),
resetErrorTagNum: () => {
errorTagNum = 0;
},
getMessageLogArgs: error => weakmapGet(hiddenMessageLogArgs, error),
takeMessageLogArgs: error => {
const result = weakmapGet(hiddenMessageLogArgs, error);
weakmapDelete(hiddenMessageLogArgs, error);
return result;
},
takeNoteLogArgsArray: (error, callback) => {
const result = takeLogArgsArray(error);
if (callback !== undefined) {
const callbacks = weakmapGet(hiddenNoteCallbackArrays, error);
if (callbacks) {
arrayPush(callbacks, callback);
} else {
weakmapSet(hiddenNoteCallbackArrays, error, [callback]);
}
}
return result || [];
},
};
freeze(loggedErrorHandler);
export { loggedErrorHandler };
// /////////////////////////////////////////////////////////////////////////////
/**
* @type {MakeAssert}
*/
const makeAssert = (optRaise = undefined, unredacted = false) => {
const details = unredacted ? unredactedDetails : redactedDetails;
const assertFailedDetails = details`Check failed`;
/** @type {AssertionFunctions['fail']} */
const fail = (
optDetails = assertFailedDetails,
errConstructor = undefined,
options = undefined,
) => {
const reason = makeError(optDetails, errConstructor, options);
if (optRaise !== undefined) {
// @ts-ignore returns `never` doesn't mean it isn't callable
optRaise(reason);
}
throw reason;
};
freeze(fail);
/** @type {AssertionUtilities['Fail']} */
const Fail = (template, ...args) => fail(details(template, ...args));
// Don't freeze or export `baseAssert` until we add methods.
// TODO If I change this from a `function` function to an arrow
// function, I seem to get type errors from TypeScript. Why?
/** @type {BaseAssert} */
function baseAssert(
flag,
optDetails = undefined,
errConstructor = undefined,
options = undefined,
) {
flag || fail(optDetails, errConstructor, options);
}
/** @type {AssertionFunctions['equal']} */
const equal = (
actual,
expected,
optDetails = undefined,
errConstructor = undefined,
options = undefined,
) => {
is(actual, expected) ||
fail(
optDetails || details`Expected ${actual} is same as ${expected}`,
errConstructor || RangeError,
options,
);
};
freeze(equal);
/** @type {AssertionFunctions['typeof']} */
const assertTypeof = (specimen, typename, optDetails) => {
// This will safely fall through if typename is not a string,
// which is what we want.
// eslint-disable-next-line valid-typeof
if (typeof specimen === typename) {
return;
}
typeof typename === 'string' || Fail`${quote(typename)} must be a string`;
if (optDetails === undefined) {
// Embed the type phrase without quotes.
const typeWithDeterminer = an(typename);
optDetails = details`${specimen} must be ${bare(typeWithDeterminer)}`;
}
fail(optDetails, TypeError);
};
freeze(assertTypeof);
/** @type {AssertionFunctions['string']} */
const assertString = (specimen, optDetails = undefined) =>
assertTypeof(specimen, 'string', optDetails);
// Note that "assert === baseAssert"
/** @type {Assert} */
const assert = assign(baseAssert, {
error: makeError,
fail,
equal,
typeof: assertTypeof,
string: assertString,
note,
details,
Fail,
quote,
bare,
makeAssert,
});
return freeze(assert);
};
freeze(makeAssert);
export { makeAssert };
/** @type {Assert} */
const assert = makeAssert();
export { assert };
// Internal, to obviate polymorphic dispatch, but may become rigorously
// consistent with @endo/error:
/** @type {AssertionFunctions['equal']} */
const assertEqual = assert.equal;
export {
assertEqual,
makeError,
note as annotateError,
redactedDetails as X,
quote as q,
bare as b,
};