* Copyright (c) 2024 Huawei Device Co., Ltd.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License 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 type { RawSourceMap } from 'typescript';
import { SourceMap } from 'magic-string';
import { SourceMapSegment, decode } from '@jridgewell/sourcemap-codec';
import assert from 'assert';
enum SegmentIndex {
ORIGINAL_COLUMN_INDEX = 0,
SOURCE_INDEX = 1,
TRANSFORMED_LINE_INDEX = 2,
TRANSFORMED_COLUMN_INDEX = 3,
NAME_INDEX = 4,
}
* The sourcemap format with decoded mappings with number type.
*/
export interface ExistingDecodedSourceMap {
file?: string;
mappings: SourceMapSegment[][];
names?: string[];
sourceRoot?: string;
sources: string[];
sourcesContent?: string[];
version: number;
}
interface BaseSource {
traceSegment(line: number, column: number, name: string): SourceMapSegmentObj | null;
}
* The source file info.
*/
export class Source implements BaseSource {
readonly content: string | null;
readonly filename: string;
isOriginal = true;
constructor(filename: string, content: string | null) {
this.filename = filename;
this.content = content;
}
traceSegment(line: number, column: number, name: string): SourceMapSegmentObj {
return { column, line, name, source: this };
}
}
* The interpreted sourcemap line and column info.
*/
export interface SourceMapSegmentObj {
column: number;
line: number;
name: string;
source: Source;
}
type MappingsNameType = { mappings: readonly SourceMapSegment[][]; names?: readonly string[] };
type TracedMappingsType = { mappings: SourceMapSegment[][]; names: string[]; sources: string[] };
* Type of the map parameter of the SourceMapLink class.
*/
export type MappingsNameTypeForTest = MappingsNameType;
* Provide api tools related to sourcemap.
*/
export class SourceMapLink implements BaseSource {
readonly mappings: readonly SourceMapSegment[][];
readonly names?: readonly string[];
readonly sources: BaseSource[];
constructor(map: MappingsNameType, sources: BaseSource[]) {
this.sources = sources;
this.names = map.names;
this.mappings = map.mappings;
}
traceMappings(): TracedMappingsType {
const tracedSources: string[] = [];
const sourceIndexMap = new Map<string, number>();
const sourcesContent: (string | null)[] = [];
const tracednames: string[] = [];
const nameIndexMap = new Map<string, number>();
const mappings = [];
for (const line of this.mappings) {
const tracedLine: SourceMapSegment[] = [];
for (const segment of line) {
if (segment.length === 1) {
continue;
}
const source = this.sources[segment[SegmentIndex.SOURCE_INDEX]];
if (!source) {
continue;
}
assert(segment.length >= 4, 'The length of the mapping segment is incorrect.');
let line: number = segment[SegmentIndex.TRANSFORMED_LINE_INDEX];
let column: number = segment[SegmentIndex.TRANSFORMED_COLUMN_INDEX];
let name: string = segment.length === 5 ? this.names[segment[SegmentIndex.NAME_INDEX]] : '';
const traced = source.traceSegment(line, column, name);
if (traced) {
this.analyzeTracedSource(traced, tracedSources, sourceIndexMap, sourcesContent);
let sourceIndex = sourceIndexMap.get(traced.source.filename);
const targetSegment: SourceMapSegment = [segment[SegmentIndex.ORIGINAL_COLUMN_INDEX], sourceIndex, traced.line, traced.column];
this.recordTracedName(traced, tracednames, nameIndexMap, targetSegment);
tracedLine.push(targetSegment);
}
}
mappings.push(tracedLine);
}
return { mappings, names: tracednames, sources: tracedSources };
}
analyzeTracedSource(traced: SourceMapSegmentObj, tracedSources: string[], sourceIndexMap: Map<string, number>, sourcesContent: (string | null)[]): void {
const content = traced.source.content;
const filename = traced.source.filename;
let sourceIndex = sourceIndexMap.get(filename);
if (sourceIndex === undefined) {
sourceIndex = tracedSources.length;
tracedSources.push(filename);
sourceIndexMap.set(filename, sourceIndex);
sourcesContent[sourceIndex] = content;
} else if (sourcesContent[sourceIndex] == null) {
sourcesContent[sourceIndex] = content;
} else if (content != null && sourcesContent[sourceIndex] !== content) {
throw new Error(`Multiple conflicting contents for sourcemap source: ${filename}`);
}
}
recordTracedName(traced: SourceMapSegmentObj, tracednames: string[], nameIndexMap: Map<string, number>, targetSegment: SourceMapSegment): void {
if (traced.name) {
const name = traced.name;
let nameIndex = nameIndexMap.get(name);
if (nameIndex === undefined) {
nameIndex = tracednames.length;
tracednames.push(name);
nameIndexMap.set(name, nameIndex);
}
targetSegment.push(nameIndex);
}
}
traceSegment(line: number, column: number, name: string): SourceMapSegmentObj | null {
const segments = this.mappings[line];
if (!segments) {
return null;
}
let binarySearchStart = 0;
let binarySearchEnd = segments.length - 1;
while (binarySearchStart <= binarySearchEnd) {
const m = (binarySearchStart + binarySearchEnd) >> 1;
const tempSegment = segments[m];
let tempColumn = tempSegment[SegmentIndex.ORIGINAL_COLUMN_INDEX];
if (tempColumn === column || binarySearchStart === binarySearchEnd) {
if (tempSegment.length === 1) {
return null;
}
const tracedSource = tempSegment[SegmentIndex.SOURCE_INDEX];
const source = this.sources[tracedSource];
if (!source) {
return null;
}
let tracedLine: number = tempSegment[SegmentIndex.TRANSFORMED_LINE_INDEX];
let tracedColumn: number = tempSegment[SegmentIndex.TRANSFORMED_COLUMN_INDEX];
let tracedName: string = tempSegment.length === 5 ? this.names[tempSegment[SegmentIndex.NAME_INDEX]] : name;
return source.traceSegment(tracedLine, tracedColumn, tracedName);
}
if (tempColumn > column) {
binarySearchEnd = m - 1;
} else {
binarySearchStart = m + 1;
}
}
return null;
}
}
* Decode the sourcemap from string format to number format.
* @param map The sourcemap with raw string format, eg. mappings: IAGS,OAAO,GAAE,MAAM,CAAA;
* @returns The sourcemap with decoded number format, eg. mappings: [4,0,3,9], [7,0,0,7], [3,0,0,2], [6,0,0,6], [1,0,0,0]
*/
export function decodeSourcemap(map: RawSourceMap): ExistingDecodedSourceMap | null {
if (!map) {
return null;
}
if (map.mappings === '') {
return { mappings: [], names: [], sources: [], version: 3 };
}
const mappings: SourceMapSegment[][] = decode(map.mappings);
return { ...map, mappings: mappings };
}
function generateChain(sourcemapChain: ExistingDecodedSourceMap[], map: RawSourceMap): void {
sourcemapChain.push(decodeSourcemap(map));
}
* Merge the sourcemaps of the two processes into the sourcemap of the complete process.
* @param previousMap The sourcemap before obfuscation process, such as ets-loader transform
* @param currentMap The sourcemap of obfuscation process
* @returns The merged sourcemap
*/
export function mergeSourceMap(previousMap: RawSourceMap, currentMap: RawSourceMap): RawSourceMap {
const sourcemapChain: ExistingDecodedSourceMap[] = [];
const sourceFileName = previousMap.sources.length === 1 ? previousMap.sources[0] : '';
const source: Source = new Source(sourceFileName, null);
generateChain(sourcemapChain, previousMap);
generateChain(sourcemapChain, currentMap);
const collapsedSourcemap: SourceMapLink = sourcemapChain.reduce(
(source: BaseSource, map: ExistingDecodedSourceMap): SourceMapLink => {
return new SourceMapLink(map, [source]);
},
source,
) as SourceMapLink;
const tracedMappings: TracedMappingsType = collapsedSourcemap.traceMappings();
const result: RawSourceMap = new SourceMap({ ...tracedMappings, file: previousMap.file }) as RawSourceMap;
result.sourceRoot = previousMap.sourceRoot;
return result;
}