/*
* Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://aws.amazon.com/apache2.0
*
* or in the "license" file accompanying this file. This file is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/
import { createHash } from "crypto";
import * as util from "util";
/**
* A shortcut for JSON and Smithy primitives, as well as documents and Smithy-
* modeled structures composed of those primitives
*/
export type Input = { [key: string]: Input } | Array | Date | Uint8Array | string | number | boolean | null;
/**
* Returns an array of duplicated values in the input. This is equivalent to using
* {@link util#isDeepStrictEqual} to compare every member of the input to all the
* other members, but with an optimization to make the runtime complexity O(n)
* instead of O(n^2).
*
* @param input an array of {@link Input}
* @return an array containing one instance of every duplicated member of the input,
* or an empty array if there are no duplicates
*/
export const findDuplicates = (input: Array): Array => {
const potentialCollisions: { [hash: string]: { value: Input; alreadyFound: boolean }[] } = {};
const collisions: Array = [];
for (const value of input) {
const valueHash = hash(value);
if (!potentialCollisions.hasOwnProperty(valueHash)) {
potentialCollisions[valueHash] = [{ value: value, alreadyFound: false }];
} else {
let duplicateFound = false;
for (const potentialCollision of potentialCollisions[valueHash]) {
if (util.isDeepStrictEqual(value, potentialCollision.value)) {
duplicateFound = true;
if (!potentialCollision.alreadyFound) {
collisions.push(value);
potentialCollision.alreadyFound = true;
}
}
}
if (!duplicateFound) {
potentialCollisions[valueHash].push({ value: value, alreadyFound: false });
}
}
}
return collisions;
};
const hash = (input: Input): string => {
return createHash("sha256").update(canonicalize(input)).digest("base64");
};
/**
* Since node's hash functions operate on strings or buffers, we need a canonical format for
* our objects in order to hash them correctly. This function turns them into string representations
* where the types are encoded in order to avoid ambiguity, for instance, between the string "1" and
* the number 1. This method sorts object keys lexicographically in order to maintain consistency.
*
* This doesn't just call JSON.stringify because we want to have firm control over the ordering of map
* keys and the handling of blobs and dates
*
* @param input a JSON-like object
* @return a canonical string representation
*/
const canonicalize = (input: Input): string => {
if (input === undefined) {
return "undefined()";
}
if (input === null) {
return "null()";
}
if (typeof input === "string" || typeof input === "number" || typeof input === "boolean") {
return `${typeof input}(${input.toString()})`;
}
if (Array.isArray(input)) {
return "array(" + input.map((i) => canonicalize(i)).join(",") + ")";
}
if (input instanceof Date) {
return "date(" + input.getTime() + ")";
}
if (input instanceof Uint8Array) {
// hashing the blob just to avoid allocating another base64 copy of its data
return "blob(" + createHash("sha256").update(input).digest("base64") + ")";
}
const contents: Array = [];
for (const key of Object.keys(input).slice().sort()) {
contents.push("key(" + key + ")->value(" + canonicalize(input[key]) + ")");
}
return "map(" + contents.join(",") + ")";
};