/* * 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[] }; /** * 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(); const sourcesContent: (string | null)[] = []; const tracednames: string[] = []; const nameIndexMap = new Map(); const mappings = []; for (const line of this.mappings) { const tracedLine: SourceMapSegment[] = []; for (const segment of line) { if (segment.length === 1) { // The number of elements is insufficient. continue; } const source = this.sources[segment[SegmentIndex.SOURCE_INDEX]]; if (!source) { continue; } // segment[2] records the line number of the code before transform, segment[3] records the column number of the code before transform. // segment[4] records the name from the names array. 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]; // If the length of the segment is 5, it will have name content. 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, sourcesContent: (string | null)[]): void { const content = traced.source.content; const filename = traced.source.filename; // Get the source index from sourceIndexMap, which is the second element of sourcemap. 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) { // Update text when content is empty. 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, 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); } // Add the fourth element: name position targetSegment.push(nameIndex); } } traceSegment(line: number, column: number, name: string): SourceMapSegmentObj | null { const segments = this.mappings[line]; if (!segments) { return null; } // Binary search segment for the target columns. let binarySearchStart = 0; let binarySearchEnd = segments.length - 1; // Get the last elemnt index. while (binarySearchStart <= binarySearchEnd) { // Calculate the intermediate index. const m = (binarySearchStart + binarySearchEnd) >> 1; const tempSegment = segments[m]; let tempColumn = tempSegment[SegmentIndex.ORIGINAL_COLUMN_INDEX]; // If a sourcemap does not have sufficient resolution to contain a necessary mapping, e.g. because it only contains line information, we // use the best approximation we could find if (tempColumn === column || binarySearchStart === binarySearchEnd) { if (tempSegment.length === 1) { // The number of elements is insufficient. 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) { // Target is in the left half binarySearchEnd = m - 1; } else { // Target is in the right half 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 }; // 3 is the sourcemap version. } 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[] = []; // The ets-loader esmodule mode processes one file at a time, so get the file name at index 1 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; }