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