Object.deepFreeze

My non-negotiable is dirt simple: I need deeply immutable frozen data

Taking into account all this, here's my new proposed version of deepFreeze:

let { freeze, getOwnPropertyNames, getOwnPropertySymbols, getOwnPropertyDescriptor } = Object;

let cache = new WeakMap();
let objectTypes = ['object', 'function'];

export const deepFreeze = (value) => {
  let immutable = true;
  if (!isDeepFrozen(value, false)) {
    for (let name of getOwnPropertyNames(value)) {
      let desc = getOwnPropertyDescriptor(value, name);
      immutable &&= desc.get || desc.set ? false : deepFreeze(desc.value);
    }
    for (let name of getOwnPropertySymbols(value)) {
      let desc = getOwnPropertyDescriptor(value, name);
      immutable &&= desc.get || desc.set ? false : deepFreeze(desc.value);
    }
    cache.set(freeze(value), immutable);
  }

  return immutable;
};

export const isDeepFrozen = (value) => !objectTypes.includes(typeof value) || value === null || cache.has(value);

export const isDeepImmutable = (value) => !objectTypes.includes(typeof value) || value === null || !!cache.get(value);

The differences from harden are:

  • deepFreeze never goes down the prototype chain. It's solely about plain objects. The caller is still responsible for understanding the difference between own and non-own properties. This is conceptually the same problem as the caller needing to check if something is a getter, but it's much more tractable in practice because of the existence of Object.hasOwn which is a fairly performant/low-level function where Object.getOwnPropertyDescriptor (to check if something is a getter) is slow and heavy and will hurt your perf if you have to call it often.
  • deepFreeze can't fail, which I see as critical for a primitive. This eliminates the need for tracking a set of items to commit. My problem with the transactional-commit behavior is that it's slow and it seems to me that the actual implementation would also need to do it to be behaviorally compatible with the polyfill. Instead of failing deepFreeze returns how complete a degree of freezing was possible to achieve. It caches this information to avoid needing to recompute it.

The main thing I've left out that harden supported was eliminating back-channel information transmission by way of freezing the descriptor function used as the getter. I still really don't like this. Break the channel by all means, but there are other ways. For example you could overwrite the implementation of Object.getOwnPropertyDescriptor (and similar fns) to not return the exact same getter function that was passed in (but rather a clean wrapper around it), just as it will not return the same descriptor object that was passed in (but rather a clean one, which is why it can be tinkered with).

Harden doesn't modify the implementation of functions it finds to ensure their return value is hardened, and getters are just functions. It simply is not feasible to do that without introducing a full membrane.

Endo.js has passStyleOf which classifies hardened objects based on their surface. If any object contains a function, including getters, it is not considered as copy-data.

So yes you do need to check the nature of the property you're interacting with on an hardened object. The only claim of harden is to make its surface immutable, not its internal implementation.

How exactly are these so different?

You wouldn't have to modify the implementation at all for this to work, would you? All I'm talking about is putting a (no-op) wrapper function around it so that it's possible to call the getter implementation but not possible to use the object identity of the getter function as a back channel for information passing.

As I said, one is fast, one is slow. That's really it. getOwnPropertyDescriptor is quite slow. It has to build up a whole descriptor object (with its own descriptors), and then that needs to be GC'd later. It's a high-level function with a high-level function's assumptions about perf.

You're referring to the idea of wrapping the getter function to check that it returns something immutable, but I'm not proposing that at all.

Here's the code I'm thinking of to be clear:


let { getOwnPropertyDescriptor } = Object;
let wrapFn = fn => (function() { fn.apply(this, arguments) });

Object.getOwnPropertyDescriptor = (obj, key) => {
  let desc = getOwnPropertyDescriptor(obj, key);
  // new getter function object every time
  // information channel is denied
  if (desc.get) desc.get = wrapFn(desc.get);
  return desc;
}

And because you're doing this when you get the property descriptor rather than when you set it, there's no interference of any kind with the original descriptor's getter function: the engine itself invokes the unwrapped version: the version you set as the descriptor.

Modifying the intrinsics is not an option. Harden is meant to be standalone and not patch how the language works. Retuning a new function identity for accessors is bound to cause problems.

Do you have some numbers to back that up. Most JIT engines don't have much overhead here

const desc = Object.getOwnPropertyDescriptor(obj, propName);
return !desc.get && !desc.set;

To be clear I'm addressing the concern you had with getters leaving a hole in the immutable graph. Getters are too common for harden to fail on them, and since they're not fundamentally different than functions, they are treated as such, with the consumer of the hardened object left to decide if they want to interact with getters (which means detecting them).

My point with replacement of the getter was about alternatives to harden failure. I think we both agree replacing getters to return hardened values is a non starter.

Good catch on desc.set! Updated my implementation.

I'm not saying you'd modify intrinsics in harden, I'm saying you'd modify them for code running inside a Compartment (or during lockdown maybe). Outside a secure environment there's no side-channel/back-channel concerns, and so no reason to tinker with this at all.

Again changing the observable behavior of Object.getOwnPropertyDescriptor (and the Reflect equivalent) is just not an acceptable approach.

You're saying all code that wants to read any data inside a secure environment needs to assume that every object property could be an adversarial getter.

I understand what you're saying but it's a bit absurd.

function reduce(action, state = 0) {
  if (!isHardened(action)) throw new Error();

  if (isGetter(action, 'type')) throw new Error();
  switch(action.type) {
    case 'Increment': {
      if (isGetter(action, 'value')) throw new Error();
      let actionValue = action.value;
      if (isGetter(actionValue, 'amount')) throw new Error();
      let amount = actionValue.amount;
      return state + amount;
    }
  }
}

I could make it a bit easier on the eyes, sure:

function reduce(action, state = 0) {
  if (!isHardened(action)) throw new Error();

  switch(safeGet(action, 'type')) {
    case 'Increment': {
      let amount = safeGet(action, 'value', 'amount');
      return state + amount;
    }
  }
}

But my point is: wasn't the goal in the first place to avoid all this by pushing validation of untrusted data to the module boundaries?

In my world you'd just write:

function reduce(action, state = 0) {
  if (!isDeepImmutable(action)) throw new Error();

  switch(action.type) {
    case 'Increment': {
      let { amount } = action.value;
      return state + amount;
    }
  }
}

Something else I would point out that deep immutability is often only one layer of validation. Once you know that a particular tree of objects is deeply immutable, then you can safely use it as a cache key in other weak sets, which allows you to validate additional properties.

For example I'm working with syntax trees where each node has a very well-known shape: { flags, type, name, tags, children, bounds, attributes }. There could be many of these nodes in a recursive structure. Once I know that the whole data structure is deeply immutable, I can also cache that it's a valid tree of syntax nodes. Then at the boundary of a function I can say "throw if tree is not a valid syntax tree" and after that I should in theory also be protected against prototype pollution for all internal property accesses to validation-required properties in the data in the tree structure...

The situation is a little more subtle than that.

In the presence of Proxy any property access can always trigger adversarial code. This is one reason we wanted to introduce a higher "stable" integrity level (or a non-trapping integrity trait). Whether harden should just freeze or should stabilize is up for debate.

Currently what harden does is guarantee you that the object shape will not change after you look at it. That is very useful on its own to be able to reason about interactions with objects, but it is indeed only a step towards fully "safe" interactions. We actually recommend that the creator of the object hardens it as soon as possible, not the consumer.

The thing is, getters are a completely valid API shape. harden does not have opinions about that, and it is up to the consumer of an object to understand the value returned by a getter may change, even if the getter itself is hardened. However because the target object (on which the getter is attached) is hardened, the caller is guaranteed that it will continue interacting with the same getter.

Because the shape of a hardened object cannot change, it's possible to build classifiers on top of them. @endo/pass-style is one possibility, and it does in fact decline to classify as immutable copy-data an object transitively containing any accessor. It does not harden the object itself. It does not currently attempt to reject Proxies, as that'd require either a proposal like stabilize to go through, or to have first run code that replaces the Proxy constructor with a stamping version so that tools like passStyleOf can detect proxies.

So in your world you'd write if (passStyleOf(harden(action)) !== 'copyRecord') throw new Error();, and after that you'd be guaranteed that property access however deep in your action would not change value or trigger getters.

Of course passStyleOf has its own opinions about what objects with behavior look like (they contain methods only, no data, and have a specific registered symbol tag). This is just the nomenclature we have found works for us. And it enables some expressive guards and pattern matching through @endo/patterns.

:100:

@agoric/store (which should get better doc and move to @endo) uses patterns and the passStyleOf classification to build more powerful collections, including the ability to compare entries by their immutable structure, or sort them.

But to re-iterate, we view harden as being an extended transitive version of freeze without opinions on what the shape of objects should be, including whether they contain accessors or not.

A notion of comparable immutable data can be built on top of that, but that requires more opinions: accessors, inherited properties, order of keys, whether symbols are acceptable, whether arrays can be sparse, or have non numeric index properties, etc.