1

I know that WeakMap and WeakSet are not iterable for security reasons, that is, “to prevent attackers from seeing the garbage collector’s internal behavior,” but then, this means you cannot clone a WeakMap or WeakSet the way you clone a Map or Set, cloned_map = new Map(existing_map), cloned_set = new Set(existing_set).

How do I clone a WeakMap or WeakSet in Javascript? By cloning, I mean creating another WeakMap or WeakSet with the same weak references.

  • Maybe you could use `Object.create`? – andriusain Nov 29 '22 at 07:40
  • It is impossible to clone them, period. It is their purpose to hide direct access to keys and values. – vitaly-t Nov 30 '22 at 14:24
  • @vitaly-t While it's currently true, the inability of accessing keys cannot be reasons for the inability of cloning (i.e. logically unrelated). – Константин Ван Nov 30 '22 at 14:31
  • 1
    Why would one want to clone a data structure which the garbage collector takes care of and where the latter maybe is one big argument for the existence of `WeakMap` and `WeakSet`? Every clone duplicates the references which might make it more complicated for a clean-up mechanism to figure out which references are really _"dead"_. – Peter Seliger Nov 30 '22 at 14:45
  • @PeterSeliger What I mean by _cloning_ is cloning a `Weak*` instance to another `Weak*`, not something like `.entries()`, `.keys()`, or `.values()`. Copying the weak references and mappings, not exposing the GC’s behavior. All `Weak*` instances are managed by the GC in the same manner, and weak references are dead when the targets they point to are dead; I don’t quite understand how having multiple weak references to the same target, which is [already possible](https://tc39.es/ecma262/multipage/managing-memory.html#sec-weak-ref-objects), complicates anything. – Константин Ван Nov 30 '22 at 17:23
  • 1
    I said _accessing keys_ and _cloning_ are “_logically unrelated_” because cloning can be done without letting the user access the keys and values and exposing when the GC collected what. The two concepts are simply different, not related. – Константин Ван Nov 30 '22 at 19:34

2 Answers2

3

Why are WeakMap/WeakSet not "cloneable"?

WeakMaps and WeakSets are not "cloneable" for the same reason as why you can't iterate them.

Namely to avoid exposing the latency between the time the key becomes inaccessible and when it is removed from the WeakMap / WeakSet. (The reasoning for this is already covered your linked question )

ECMAScript 2023 Language Specification, 24.3 WeakMap Objects

An implementation may impose an arbitrarily determined latency between the time a key/value pair of a WeakMap becomes inaccessible and the time when the key/value pair is removed from the WeakMap. If this latency was observable to ECMAScript program, it would be a source of indeterminacy that could impact program execution. For that reason, an ECMAScript implementation must not provide any means to observe a key of a WeakMap that does not require the observer to present the observed key.


How iterability and clonability are linked

Think about how new WeakMap(existingWeakMap) would need to be implemented.
To create a new WeakMap from an existing one would require iterating over its elements and copying them over to the new one.

And depending on how many elements there are in the WeakMap, this operation would take a varying amount of time (it would take a lot longer to copy a WeakMap with 100'000 entries than to copy one with none).

And that gives you an attack vector: You can guesstimate the number of key-value pairs within the WeakMap by measuring how long it takes to clone it.

Here's a runnable snippet that uses this technique to guess to number of entries within a Map (could be easily used against WeakMap, if it were clonable):

Note that due to Spectre mitigations performance.now() in browsers is typically rounded, so a larger margin of error in the guesses should be expected.

function measureCloneTime(map) {
  const begin = performance.now();
  const cloneMap = new Map(map);
  const end = performance.now();
  return end-begin;
}

function measureAvgCloneTime(map, numSamples = 50) {
  let timeSum = 0;
  for(let i = 0; i < numSamples; i++) {
    timeSum += measureCloneTime(map);
  }

  return timeSum / numSamples;
}

function makeMapOfSize(n) {
  return new Map(Array(n).fill(null).map(() => [{}, {}]));
}

// prime JIT
for(let i = 0; i < 10000; i++) {
  measureAvgCloneTime(makeMapOfSize(50));
}

const avgCloneTimes = [
  {size: 2**6, time: measureAvgCloneTime(makeMapOfSize(2**6))},
  {size: 2**7, time: measureAvgCloneTime(makeMapOfSize(2**7))},
  {size: 2**8, time: measureAvgCloneTime(makeMapOfSize(2**8))},
  {size: 2**9, time: measureAvgCloneTime(makeMapOfSize(2**9))},
  {size: 2**10, time: measureAvgCloneTime(makeMapOfSize(2**10))},
  {size: 2**11, time: measureAvgCloneTime(makeMapOfSize(2**11))},
  {size: 2**12, time: measureAvgCloneTime(makeMapOfSize(2**12))},
  {size: 2**13, time: measureAvgCloneTime(makeMapOfSize(2**13))},
  {size: 2**14, time: measureAvgCloneTime(makeMapOfSize(2**14))},
];

function guessMapSizeBasedOnCloneSpeed(map) {
  const cloneTime = measureAvgCloneTime(map);

  let closestMatch = avgCloneTimes.find(e => e.time > cloneTime);
  if(!closestMatch) {
    closestMatch = avgCloneTimes[avgCloneTimes.length - 1];
  }

  const sizeGuess = Math.round(
    (cloneTime / closestMatch.time) * closestMatch.size
  );

  console.log("Real Size: " + map.size + " - Guessed Size: " + sizeGuess);
}


guessMapSizeBasedOnCloneSpeed(makeMapOfSize(1000));
guessMapSizeBasedOnCloneSpeed(makeMapOfSize(4000));
guessMapSizeBasedOnCloneSpeed(makeMapOfSize(6000));
guessMapSizeBasedOnCloneSpeed(makeMapOfSize(10000));

On my machine (Ubuntu 20, Chrome 107) i got the following output (YMMV):

Real Size: 1000  - Guessed Size: 1037
Real Size: 4000  - Guessed Size: 3462
Real Size: 6000  - Guessed Size: 6329
Real Size: 10000 - Guessed Size: 9889

As you can see it is incredibly easy to guess the size of a Map just by cloning it. (by refining the algorithm / taking more samples / using a more accurate time source it could be made even more accurate)

And that's why you can't clone WeakMap / WeakSet.


A possible alternative

If you need a clonable / iterable WeakMap / WeakSet you could build your own by using WeakRef and FinalizationRegistry.

Here's an example how you could build an iterable WeakMap:

class IterableWeakMap {
  #weakMap = new WeakMap();
  #refSet = new Set();
  #registry = new FinalizationRegistry(this.#cleanup.bind(this));

  #cleanup(value) {
    this.#refSet.delete(value);
  }

  constructor(iterable) {
    if(iterable) {
      for(const [key, value] of iterable) {
        this.set(key, value);
      }
    }
  }

  get(key) {
    return this.#weakMap.get(key)?.value;
  }

  has(key) {
    return this.#weakMap.has(key);
  }

  set(key, value) {
    let entry = this.#weakMap.get(key);
    if(!entry) {
      const ref = new WeakRef(key);
      this.#registry.register(key, ref, key);
      entry = {ref, value: null};
      this.#weakMap.set(key, entry);
      this.#refSet.add(ref);
    }

    entry.value = value;
    return this;
  }

  delete(key) {
    const entry = this.#weakMap.get(key);
    if(!entry) {
      return false;
    }

    this.#weakMap.delete(key);
    this.#refSet.delete(entry.ref);
    this.#registry.unregister(key);

    return true;
  }

  clear() {
    for(const ref of this.#refSet) {
      const el = ref.deref();
      if(el !== undefined) {
        this.#registry.unregister(el);
      }
    }

    this.#weakMap = new WeakMap();
    this.#refSet.clear();
  }

  *entries() {
    for(const ref of this.#refSet) {
      const el = ref.deref();
      if(el !== undefined) {
        yield [el, this.#weakMap.get(el).value];
      }
    }
  }

  *keys() {
    for(const ref of this.#refSet) {
      const el = ref.deref();
      if(el !== undefined) {
        yield el;
      }
    }
  }

  *values() {
    for(const ref of this.#refSet) {
      const el = ref.deref();
      if(el !== undefined) {
        yield this.#weakMap.get(el).value;
      }
    }
  }

  forEach(callbackFn, thisArg) {
    for(const [key, value] of this.entries()) {
      callbackFn.call(thisArg, value, key, this);
    }
  }

  [Symbol.iterator]() {
    return this.entries();
  }

  get size() {
    let size = 0;
    for(const key of this.keys()) {
      size++;
    }

    return size;
  }

  static get [Symbol.species]() {
    return IterableWeakMap;
  }
}


// Usage Example:
let foo = {foo: 42};
let bar = {bar: 42};

const map = new IterableWeakMap([
  [foo, "foo"],
  [bar, "bar"]
]);
const clonedMap = new IterableWeakMap(map);

console.log([...clonedMap.entries()]);
Turtlefight
  • 9,420
  • 2
  • 23
  • 40
  • 1
    "_Cloning it is not [timing-attack](https://en.m.wikipedia.org/wiki/Timing_attack)-proof and thus violates the ECMAScript specification_".... This, I haven't thought of, and it's a pretty fair point! Thank you for your insights. – Константин Ван Dec 06 '22 at 02:04
  • 1
    I'm not sure a timing attack vector is the real reason why it's not possible. I think it's simply because `WeakMap`/`WeakSet` were added *before* `WeakRef`. And they didn't want to expose the inner workings of the garbage collector too much. After all, an attacker with access to your WeakMap can already override the `set` method and observe any parameters passed to it. – Jespertheend May 06 '23 at 23:02
0

It can be done, trusting that you run your code before whatever is making a weakmap by tracking WeakMap.prototype.set and WeakMap.prototype.delete

However creating a clone needs me to keep my own view of things so this might result in no weakmap ever being collected by garbage ;-;

//the code you run first
(()=>{
let MAPS=new Map()
let DELETE=WeakMap.prototype.delete, SET=WeakMap.prototype.set
let BIND=Function.prototype.call.bind(Function.prototype.bind)
let APPLY=(FN,THIS,ARGS)=>BIND(Function.prototype.apply,FN)(THIS,ARGS)
WeakMap.prototype.set=
function(){
    let theMap=MAPS.get(this)
    if(!theMap){
        theMap=new Map()
        MAPS.set(this,theMap)
    }
    APPLY(theMap.set,theMap,arguments)
    return APPLY(SET,this,arguments)
}
WeakMap.prototype.delete=
function(){
    let theMap=MAPS.get(this)
    if(!theMap){
        theMap=new Map()
        MAPS.set(this,theMap)
    }
    APPLY(theMap.delete,theMap,arguments)
    return APPLY(DELETE,this,arguments)
}
function cloneWM(target){
    let theClone=new WeakMap()
    MAPS.get(target).forEach((value,key)=>{
        APPLY(SET,theClone,[key,value])
    })
    return theClone
}
window.cloneWM=cloneWM
})()



//the example(go on devtools console to see it properly)
let w=new WeakMap()
w.set({a:1},'f')
w.set({b:2},'g')
w.set(window,'a')
w.delete(window)
console.log([w,cloneWM(w)])
console.log("go on devtools console to see it properly")
The Bomb Squad
  • 4,192
  • 1
  • 9
  • 17
  • to whoever minused 1 on my post, while it is not ideal question this is a working answer – The Bomb Squad Nov 30 '22 at 18:56
  • If you store the keys in a separate `Map` then there will always be a strong reference to that key (i.e. it will never be garbage-collected), so this kind of defeats the purpose of using a `WeakMap` in the first place. – Turtlefight Dec 05 '22 at 20:32