1/* 2 * Copyright (c) 2024 Huawei Device Co., Ltd. 3 * Licensed under the Apache License, Version 2.0 (the "License"); 4 * you may not use this file except in compliance with the License. 5 * You may obtain a copy of the License at 6 * 7 * http://www.apache.org/licenses/LICENSE-2.0 8 * 9 * Unless required by applicable law or agreed to in writing, software 10 * distributed under the License is distributed on an "AS IS" BASIS, 11 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 * See the License for the specific language governing permissions and 13 * limitations under the License. 14 */ 15 16import type { RawSourceMap } from 'typescript'; 17import { SourceMap } from 'magic-string'; 18import { SourceMapSegment, decode } from '@jridgewell/sourcemap-codec'; 19import assert from 'assert'; 20 21enum SegmentIndex { 22 ORIGINAL_COLUMN_INDEX = 0, 23 SOURCE_INDEX = 1, 24 TRANSFORMED_LINE_INDEX = 2, 25 TRANSFORMED_COLUMN_INDEX = 3, 26 NAME_INDEX = 4, 27} 28 29/** 30 * The sourcemap format with decoded mappings with number type. 31 */ 32export interface ExistingDecodedSourceMap { 33 file?: string; 34 mappings: SourceMapSegment[][]; 35 names?: string[]; 36 sourceRoot?: string; 37 sources: string[]; 38 sourcesContent?: string[]; 39 version: number; 40} 41 42interface BaseSource { 43 traceSegment(line: number, column: number, name: string): SourceMapSegmentObj | null; 44} 45 46/** 47 * The source file info. 48 */ 49export class Source implements BaseSource { 50 readonly content: string | null; 51 readonly filename: string; 52 isOriginal = true; 53 54 constructor(filename: string, content: string | null) { 55 this.filename = filename; 56 this.content = content; 57 } 58 59 traceSegment(line: number, column: number, name: string): SourceMapSegmentObj { 60 return { column, line, name, source: this }; 61 } 62} 63 64/** 65 * The interpreted sourcemap line and column info. 66 */ 67export interface SourceMapSegmentObj { 68 column: number; 69 line: number; 70 name: string; 71 source: Source; 72} 73 74type MappingsNameType = { mappings: readonly SourceMapSegment[][]; names?: readonly string[] }; 75type TracedMappingsType = { mappings: SourceMapSegment[][]; names: string[]; sources: string[] }; 76 77/** 78 * Provide api tools related to sourcemap. 79 */ 80export class SourceMapLink implements BaseSource { 81 readonly mappings: readonly SourceMapSegment[][]; 82 readonly names?: readonly string[]; 83 readonly sources: BaseSource[]; 84 85 constructor(map: MappingsNameType, sources: BaseSource[]) { 86 this.sources = sources; 87 this.names = map.names; 88 this.mappings = map.mappings; 89 } 90 91 traceMappings(): TracedMappingsType { 92 const tracedSources: string[] = []; 93 const sourceIndexMap = new Map<string, number>(); 94 const sourcesContent: (string | null)[] = []; 95 const tracednames: string[] = []; 96 const nameIndexMap = new Map<string, number>(); 97 98 const mappings = []; 99 100 for (const line of this.mappings) { 101 const tracedLine: SourceMapSegment[] = []; 102 103 for (const segment of line) { 104 if (segment.length === 1) { // The number of elements is insufficient. 105 continue; 106 } 107 const source = this.sources[segment[SegmentIndex.SOURCE_INDEX]]; 108 if (!source) { 109 continue; 110 } 111 // segment[2] records the line number of the code before transform, segment[3] records the column number of the code before transform. 112 // segment[4] records the name from the names array. 113 assert(segment.length >= 4, 'The length of the mapping segment is incorrect.'); 114 let line: number = segment[SegmentIndex.TRANSFORMED_LINE_INDEX]; 115 let column: number = segment[SegmentIndex.TRANSFORMED_COLUMN_INDEX]; 116 // If the length of the segment is 5, it will have name content. 117 let name: string = segment.length === 5 ? this.names[segment[SegmentIndex.NAME_INDEX]] : ''; 118 const traced = source.traceSegment(line, column, name); 119 120 if (traced) { 121 this.analyzeTracedSource(traced, tracedSources, sourceIndexMap, sourcesContent); 122 let sourceIndex = sourceIndexMap.get(traced.source.filename); 123 const targetSegment: SourceMapSegment = [segment[SegmentIndex.ORIGINAL_COLUMN_INDEX], sourceIndex, traced.line, traced.column]; 124 this.recordTracedName(traced, tracednames, nameIndexMap, targetSegment); 125 tracedLine.push(targetSegment); 126 } 127 } 128 129 mappings.push(tracedLine); 130 } 131 132 return { mappings, names: tracednames, sources: tracedSources }; 133 } 134 135 analyzeTracedSource(traced: SourceMapSegmentObj, tracedSources: string[], sourceIndexMap: Map<string, number>, sourcesContent: (string | null)[]): void { 136 const content = traced.source.content; 137 const filename = traced.source.filename; 138 // Get the source index from sourceIndexMap, which is the second element of sourcemap. 139 let sourceIndex = sourceIndexMap.get(filename); 140 if (sourceIndex === undefined) { 141 sourceIndex = tracedSources.length; 142 tracedSources.push(filename); 143 sourceIndexMap.set(filename, sourceIndex); 144 sourcesContent[sourceIndex] = content; 145 } else if (sourcesContent[sourceIndex] == null) { // Update text when content is empty. 146 sourcesContent[sourceIndex] = content; 147 } else if (content != null && sourcesContent[sourceIndex] !== content) { 148 throw new Error(`Multiple conflicting contents for sourcemap source: ${filename}`); 149 } 150 } 151 152 recordTracedName(traced: SourceMapSegmentObj, tracednames: string[], nameIndexMap: Map<string, number>, targetSegment: SourceMapSegment): void { 153 if (traced.name) { 154 const name = traced.name; 155 let nameIndex = nameIndexMap.get(name); 156 if (nameIndex === undefined) { 157 nameIndex = tracednames.length; 158 tracednames.push(name); 159 nameIndexMap.set(name, nameIndex); 160 } 161 // Add the fourth element: name position 162 targetSegment.push(nameIndex); 163 } 164 } 165 166 traceSegment(line: number, column: number, name: string): SourceMapSegmentObj | null { 167 const segments = this.mappings[line]; 168 if (!segments) { 169 return null; 170 } 171 172 // Binary search segment for the target columns. 173 let binarySearchStart = 0; 174 let binarySearchEnd = segments.length - 1; // Get the last elemnt index. 175 176 while (binarySearchStart <= binarySearchEnd) { 177 // Calculate the intermediate index. 178 const m = (binarySearchStart + binarySearchEnd) >> 1; 179 const tempSegment = segments[m]; 180 let tempColumn = tempSegment[SegmentIndex.ORIGINAL_COLUMN_INDEX]; 181 // If a sourcemap does not have sufficient resolution to contain a necessary mapping, e.g. because it only contains line information, we 182 // use the best approximation we could find 183 if (tempColumn === column || binarySearchStart === binarySearchEnd) { 184 if (tempSegment.length === 1) { // The number of elements is insufficient. 185 return null; 186 } 187 const tracedSource = tempSegment[SegmentIndex.SOURCE_INDEX]; 188 const source = this.sources[tracedSource]; 189 if (!source) { 190 return null; 191 } 192 193 let tracedLine: number = tempSegment[SegmentIndex.TRANSFORMED_LINE_INDEX]; 194 let tracedColumn: number = tempSegment[SegmentIndex.TRANSFORMED_COLUMN_INDEX]; 195 let tracedName: string = tempSegment.length === 5 ? this.names[tempSegment[SegmentIndex.NAME_INDEX]] : name; 196 return source.traceSegment(tracedLine, tracedColumn, tracedName); 197 } 198 if (tempColumn > column) { 199 // Target is in the left half 200 binarySearchEnd = m - 1; 201 } else { 202 // Target is in the right half 203 binarySearchStart = m + 1; 204 } 205 } 206 207 return null; 208 } 209} 210 211/** 212 * Decode the sourcemap from string format to number format. 213 * @param map The sourcemap with raw string format, eg. mappings: IAGS,OAAO,GAAE,MAAM,CAAA; 214 * @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] 215 */ 216export function decodeSourcemap(map: RawSourceMap): ExistingDecodedSourceMap | null { 217 if (!map) { 218 return null; 219 } 220 if (map.mappings === '') { 221 return { mappings: [], names: [], sources: [], version: 3 }; // 3 is the sourcemap version. 222 } 223 const mappings: SourceMapSegment[][] = decode(map.mappings); 224 return { ...map, mappings: mappings }; 225} 226 227function generateChain(sourcemapChain: ExistingDecodedSourceMap[], map: RawSourceMap): void { 228 sourcemapChain.push(decodeSourcemap(map)); 229} 230 231/** 232 * Merge the sourcemaps of the two processes into the sourcemap of the complete process. 233 * @param previousMap The sourcemap before obfuscation process, such as ets-loader transform 234 * @param currentMap The sourcemap of obfuscation process 235 * @returns The merged sourcemap 236 */ 237export function mergeSourceMap(previousMap: RawSourceMap, currentMap: RawSourceMap): RawSourceMap { 238 const sourcemapChain: ExistingDecodedSourceMap[] = []; 239 // The ets-loader esmodule mode processes one file at a time, so get the file name at index 1 240 const sourceFileName = previousMap.sources.length === 1 ? previousMap.sources[0] : ''; 241 const source: Source = new Source(sourceFileName, null); 242 generateChain(sourcemapChain, previousMap); 243 generateChain(sourcemapChain, currentMap); 244 const collapsedSourcemap: SourceMapLink = sourcemapChain.reduce( 245 (source: BaseSource, map: ExistingDecodedSourceMap): SourceMapLink => { 246 return new SourceMapLink(map, [source]); 247 }, 248 source, 249 ) as SourceMapLink; 250 const tracedMappings: TracedMappingsType = collapsedSourcemap.traceMappings(); 251 const result: RawSourceMap = new SourceMap({ ...tracedMappings, file: previousMap.file }) as RawSourceMap; 252 result.sourceRoot = previousMap.sourceRoot; 253 return result; 254} 255