diff --git a/docs/cookbook-mock.md b/docs/cookbook-mock.md new file mode 100644 index 0000000000..c9ad89d519 --- /dev/null +++ b/docs/cookbook-mock.md @@ -0,0 +1,18 @@ +## Mock Temporal example + +This is an example of how to create a "locked-down" version of Temporal that supports exactly the same interface, and is indistinguishable from the original, except that the date, time, time zone, and time zone data are under the control of the creator. + +This is useful for secure environments like [SES](https://github.com/Agoric/ses-shim) where no information about the host system should be leaked to the program being run; purely functional environments like [Elm](https://elm-lang.org/) where functions must be pure even if the browser's locale data is updated; and mocking for testing purposes, where runs must be deterministic. + +This is an example of an approach to take, illustrating shadowing the locale data, introducing a controllable clock time, and freezing Temporal. +Not everything in this example is needed for every application. +For example, in a test harness, you would probably only need to replace `Temporal.now` with a version using a controllable clock and constant time zone, and not need to freeze the Temporal object, or replace `Function.prototype.toString`. + +At the same time, this example does not claim to be secure or complete enough for real security applications. +Other information can leak through channels not considered here, such as differences in performance of the underlying Temporal operations. + +> **NOTE**: This is a very specialized use of Temporal and is not something you would normally need to do. + +```javascript +{{cookbook/makeMockTemporal.mjs}} +``` diff --git a/docs/cookbook.md b/docs/cookbook.md index 765aeb78dd..af05fa2f43 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -471,3 +471,9 @@ Extend Temporal to support arbitrarily-large years (e.g., **+635427810-02-02**) An example of using `Temporal.TimeZone` for other purposes than a standard time zne. → [NYSE time zone](cookbook-nyse.md) + +### Locked-down Temporal + +"Lock down" the Temporal object so that it doesn't leak any information about the host system, and the system clock is controllable, for use in security applications or for mocking in tests. + +→ [Locked-down Temporal](cookbook-mock.md) diff --git a/docs/cookbook/makeMockTemporal.mjs b/docs/cookbook/makeMockTemporal.mjs new file mode 100644 index 0000000000..d5e8617419 --- /dev/null +++ b/docs/cookbook/makeMockTemporal.mjs @@ -0,0 +1,270 @@ +// First of all, create a controllable clock object that underlies the +// functions in the Temporal.now namespace, that we can tick forward or backward +// at will. +// We'll use the clock to remove Temporal's access to the system clock below. + +class Clock { + epochNs = 0n; + tick(ticks = 1) { + this.epochNs += BigInt(ticks); + } +} +const clock = new Clock(); + +// Save the original Temporal functions that we will override but still need +// access to internally. + +const realTemporalCalendar = Temporal.Calendar; +const realCalendarFrom = Temporal.Calendar.from; +const realTemporalTimeZone = Temporal.TimeZone; +const realTimeZoneFrom = Temporal.TimeZone.from; +const realTemporalNow = Temporal.now; + +// Override the Temporal.Calendar constructor and Temporal.Calendar.from to +// disallow all calendars except the iso8601 calendar, otherwise insecure code +// might be able to tell something about the version of the host system's +// locale data. + +class Calendar extends realTemporalCalendar { + constructor(identifier) { + if (identifier !== 'iso8601') { + // match error message + throw new RangeError(`Invalid calendar: ${identifier}`); + } + super(identifier); + } + + static from(item) { + const calendar = realCalendarFrom.call(realTemporalCalendar, item); + const identifier = calendar.toString(); + const constructor = Object.is(this, realTemporalCalendar) ? Calendar : this; + return new constructor(identifier); + } +} +Object.getOwnPropertyNames(realTemporalCalendar.prototype).forEach((name) => { + if (name === 'constructor') return; + const desc = Object.getOwnPropertyDescriptor(realTemporalCalendar.prototype, name); + Object.defineProperty(Calendar.prototype, name, desc); +}); + +// Do the same for the Temporal.TimeZone constructor and Temporal.TimeZone.from +// to allow only offset time zones and the various aliases for UTC, otherwise +// insecure code might be able to tell something about the version of the host +// system's time zone database. + +class TimeZone extends realTemporalTimeZone { + constructor(identifier) { + const matchOffset = /^[+\u2212-][0-2][0-9](?::?[0-5][0-9](?::?[0-5][0-9](?:[.,]\d{1,9})?)?)?$/; + const matchUTC = /^UTC|Etc\/UTC|Etc\/GMT(?:[-+]\d{1,2})?$/; + if (!matchUTC.test(identifier) && !matchOffset.test(identifier)) { + // match error message + throw new RangeError(`Invalid time zone specified: ${identifier}`); + } + super(identifier); + } + + static from(item) { + const timeZone = realTimeZoneFrom.call(realTemporalTimeZone, item); + const identifier = timeZone.toString(); + const constructor = Object.is(this, realTemporalTimeZone) ? TimeZone : this; + return new constructor(identifier); + } +} +Object.getOwnPropertyNames(realTemporalTimeZone.prototype).forEach((name) => { + if (name === 'constructor') return; + const desc = Object.getOwnPropertyDescriptor(realTemporalTimeZone.prototype, name); + Object.defineProperty(TimeZone.prototype, name, desc); +}); + +// Override the functions in the Temporal.now namespace using our patched clock, +// calendar, and time zone. + +function instant() { + return new Temporal.Instant(clock.epochNs); +} + +function plainDateTime(calendarLike, temporalTimeZoneLike = timeZone()) { + const timeZone = TimeZone.from(temporalTimeZoneLike); + const calendar = Calendar.from(calendarLike); + const inst = instant(); + return timeZone.getPlainDateTimeFor(inst, calendar); +} + +function plainDateTimeISO(temporalTimeZoneLike = timeZone()) { + const timeZone = TimeZone.from(temporalTimeZoneLike); + const calendar = new Calendar('iso8601'); + const inst = instant(); + return timeZone.getPlainDateTimeFor(inst, calendar); +} + +function zonedDateTime(calendarLike, temporalTimeZoneLike = timeZone()) { + const timeZone = TimeZone.from(temporalTimeZoneLike); + const calendar = Calendar.from(calendarLike); + return new Temporal.ZonedDateTime(clock.epochNs, timeZone, calendar); +} + +function zonedDateTimeISO(temporalTimeZoneLike = timeZone()) { + const timeZone = TimeZone.from(temporalTimeZoneLike); + const calendar = new Calendar('iso8601'); + return new Temporal.ZonedDateTime(clock.epochNs, timeZone, calendar); +} + +function plainDate(calendarLike, temporalTimeZoneLike = timeZone()) { + const pdt = plainDateTime(calendarLike, temporalTimeZoneLike); + const f = pdt.getISOFields(); + return new Temporal.PlainDate(f.isoYear, f.isoMonth, f.isoDay, f.calendar); +} + +function plainDateISO(temporalTimeZoneLike = timeZone()) { + const pdt = plainDateTimeISO(temporalTimeZoneLike); + const f = pdt.getISOFields(); + return new Temporal.PlainDate(f.isoYear, f.isoMonth, f.isoDay, f.calendar); +} + +function plainTimeISO(temporalTimeZoneLike = timeZone()) { + const pdt = plainDateTimeISO(temporalTimeZoneLike); + const f = pdt.getISOFields(); + return new Temporal.PlainTime( + f.isoHour, + f.isoMinute, + f.isoSecond, + f.isoMillisecond, + f.isoMicrosecond, + f.isoNanosecond + ); +} + +function timeZone() { + return new TimeZone('UTC'); +} + +// We now have everything we need to lock down Temporal, but if we want the +// insecure code to run in an indistinguishable environment from an unlocked +// Temporal, then we have to do a few more things, such as make sure that +// toString() gives the same result for the patched functions as it would for +// the original functions. + +// This example code is not exhaustive, but this is a sample of the concerns +// that a secure environment would have to address. + +const realFunctionToString = Function.prototype.toString; +const functionToString = function toString() { + const patchedFunctions = new Map([ + [Calendar, realTemporalCalendar], + [Calendar.from, realCalendarFrom], + [instant, realTemporalNow.instant], + [plainDate, realTemporalNow.plainDate], + [plainDateISO, realTemporalNow.plainDateISO], + [plainDateTime, realTemporalNow.plainDateTime], + [plainDateTimeISO, realTemporalNow.plainDateTimeISO], + [plainTimeISO, realTemporalNow.plainTimeISO], + [timeZone, realTemporalNow.timeZone], + [TimeZone, realTemporalTimeZone], + [TimeZone.from, realTimeZoneFrom], + [toString, realFunctionToString], + [zonedDateTime, realTemporalNow.zonedDateTime], + [zonedDateTimeISO, realTemporalNow.zonedDateTimeISO] + ]); + if (patchedFunctions.has(this)) { + return realFunctionToString.apply(patchedFunctions.get(this), arguments); + } + return realFunctionToString.apply(this, arguments); +}; + +// Finally, freeze the Temporal object and all of its properties. +// (Because this is done before any user code runs, we can use Temporal APIs in +// the functions above. Otherwise we'd need to save the original APIs in case +// user code overrode them.) + +function deepFreeze(object, path) { + Object.getOwnPropertyNames(object).forEach((name) => { + // Avoid .prototype.constructor endless loop + if (name === 'constructor') return; + + const desc = Object.getOwnPropertyDescriptor(object, name); + + if (desc.value) { + const value = desc.value; + if (typeof value === 'object' || typeof value === 'function') { + deepFreeze(value, `${path}.${name}`); + } + } + if (desc.get) { + deepFreeze(desc.get, `${path}.get ${name}`); + } + if (desc.set) { + deepFreeze(desc.set, `${path}.set ${name}`); + } + }); + + return Object.freeze(object); +} + +// This is the function that does the actual patching to lock down Temporal. It +// must run before any user code does. + +function makeMockTemporal() { + realTemporalTimeZone.from = TimeZone.from; + realTemporalCalendar.from = Calendar.from; + Temporal.Calendar = Calendar; + Temporal.TimeZone = TimeZone; + Temporal.now = { + instant, + plainDateTime, + plainDateTimeISO, + plainDate, + plainDateISO, + plainTimeISO, + timeZone, + zonedDateTime, + zonedDateTimeISO + }; + deepFreeze(Temporal, 'Temporal'); + Function.prototype.toString = functionToString; +} + +// Check that we cannot distinguish the mock Temporal from the real one by +// looking at some metadata; save the original metadata for later +const realTemporalNowPlainDateToString = Temporal.now.plainDate.toString(); +const realTemporalNowPlainDateOwnProperties = Object.getOwnPropertyDescriptors(Temporal.now.plainDate); + +// After this call, Temporal is locked down. +makeMockTemporal(); + +// The clock starts at midnight UTC January 1, 1970, and is advanced manually. +assert.equal(Temporal.now.instant().toString(), '1970-01-01T00:00:00Z'); +clock.tick(1_000_000_000n); +assert.equal(Temporal.now.instant().toString(), '1970-01-01T00:00:01Z'); +clock.tick(86400_000_000_000n); +assert.equal(Temporal.now.instant().toString(), '1970-01-02T00:00:01Z'); + +// The other functions in the Temporal.now namespace use the same clock. +assert.equal(Temporal.now.plainDateTimeISO().toString(), '1970-01-02T00:00:01'); +assert.equal(Temporal.now.plainDateISO().toString(), '1970-01-02'); +assert.equal(Temporal.now.plainTimeISO().toString(), '00:00:01'); +assert.equal(Temporal.now.zonedDateTimeISO().toString(), '1970-01-02T00:00:01+00:00[UTC]'); + +// Time zones other than UTC and calendars other than ISO are not provided. +assert.throws(() => Temporal.ZonedDateTime.from('2021-02-12T16:18[America/Vancouver]'), RangeError); +assert.throws(() => Temporal.PlainDate.from('2021-02-12[u-ca-gregory]'), RangeError); + +// Constructing unsupported time zones directly doesn't work either. +assert.throws(() => new Temporal.TimeZone('America/Vancouver'), RangeError); +assert.throws(() => Temporal.TimeZone.from('America/Vancouver'), RangeError); +assert.throws(() => new Temporal.Calendar('gregory'), RangeError); +assert.throws(() => Temporal.Calendar.from('gregory'), RangeError); + +// UTC, offset time zones, and their aliases are still supported. +assert.equal(new Temporal.TimeZone('-08:00').toString(), '-08:00'); +assert.equal(new Temporal.TimeZone('Etc/UTC').toString(), 'UTC'); +assert.equal(new Temporal.TimeZone('Etc/GMT+8').toString(), 'Etc/GMT+8'); + +// Check that our function metadata is equal to what we saved earlier... +assert.equal(Temporal.now.plainDate.toString(), realTemporalNowPlainDateToString); + +// ...except take into account that we've frozen the Temporal object. +Object.values(realTemporalNowPlainDateOwnProperties).forEach((desc) => { + desc.configurable = false; + desc.writable = false; +}); +assert.deepEqual(Object.getOwnPropertyDescriptors(Temporal.now.plainDate), realTemporalNowPlainDateOwnProperties); diff --git a/polyfill/package.json b/polyfill/package.json index 50a023d7e6..fcd689a7d4 100644 --- a/polyfill/package.json +++ b/polyfill/package.json @@ -9,7 +9,7 @@ "scripts": { "coverage": "c8 report --reporter html", "test": "node --no-warnings --experimental-modules --icu-data-dir node_modules/full-icu --loader ./test/resolve.source.mjs ./test/all.mjs", - "test-cookbook": "TEST=all npm run test-cookbook-one && TEST=stockExchangeTimeZone npm run test-cookbook-one", + "test-cookbook": "TEST=all npm run test-cookbook-one && TEST=stockExchangeTimeZone npm run test-cookbook-one && TEST=makeMockTemporal npm run test-cookbook-one", "test-cookbook-one": "node --no-warnings --experimental-modules --icu-data-dir node_modules/full-icu --loader ./test/resolve.cookbook.mjs ../docs/cookbook/$TEST.mjs", "test262": "./ci_test.sh", "codecov:tests": "NODE_V8_COVERAGE=coverage/tmp npm run test && c8 report --reporter=text-lcov > coverage/tests.lcov && codecov -F tests -f coverage/tests.lcov",