mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-02-23 19:49:56 +01:00
531 lines
16 KiB
JavaScript
531 lines
16 KiB
JavaScript
/*
|
|
This file is part of the Notesnook project (https://notesnook.com/)
|
|
|
|
Copyright (C) 2023 Streetwriters (Private) Limited
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU General Public License as published by
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License
|
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
/**
|
|
* Deobfuscate a minified JavaScript stack trace using source maps.
|
|
*
|
|
* Usage:
|
|
* node scripts/deobfuscate.mjs [options] [stack-trace-file]
|
|
*
|
|
* If no file is given the script reads from stdin. The stack trace may
|
|
* contain literal spaces or URL-encoded "+" characters in place of spaces.
|
|
*
|
|
* Options:
|
|
* --base-url <url> Base URL for fetching remote .map files.
|
|
* Default: https://app.notesnook.com/assets/
|
|
* --local <dir> Directory to resolve .map files from before fetching
|
|
* remotely. Useful when you have a local build handy.
|
|
* --cache-dir <dir> Where to cache downloaded source maps on disk.
|
|
* Default: ~/.cache/notesnook/sourcemaps
|
|
* --no-cache Skip the on-disk cache; always fetch fresh copies.
|
|
* --context <n> Lines of original source context to show after each
|
|
* resolved frame (default: 2, 0 to disable).
|
|
* --help Print this help message.
|
|
*/
|
|
|
|
import https from "https";
|
|
import http from "http";
|
|
import { readFileSync, existsSync, mkdirSync, writeFileSync } from "fs";
|
|
import { glob } from "fs/promises";
|
|
import path from "path";
|
|
import os from "os";
|
|
import { fileURLToPath } from "url";
|
|
import { URL } from "url";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Argument parsing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const argv = process.argv.slice(2);
|
|
|
|
function parseArgs(argv) {
|
|
const opts = {
|
|
baseUrl: "https://app.notesnook.com/assets/",
|
|
cacheDir: path.join(os.homedir(), ".cache", "notesnook", "sourcemaps"),
|
|
localDir: null,
|
|
noCache: false,
|
|
context: 2,
|
|
help: false,
|
|
inputFile: null
|
|
};
|
|
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const arg = argv[i];
|
|
if (arg === "--help" || arg === "-h") {
|
|
opts.help = true;
|
|
} else if (arg === "--no-cache") {
|
|
opts.noCache = true;
|
|
} else if (arg === "--base-url") {
|
|
opts.baseUrl = argv[++i];
|
|
if (!opts.baseUrl.endsWith("/")) opts.baseUrl += "/";
|
|
} else if (arg === "--local") {
|
|
opts.localDir = argv[++i];
|
|
} else if (arg === "--cache-dir") {
|
|
opts.cacheDir = argv[++i];
|
|
} else if (arg === "--context" || arg === "-c") {
|
|
opts.context = parseInt(argv[++i], 10);
|
|
} else if (!arg.startsWith("-")) {
|
|
opts.inputFile = arg;
|
|
} else {
|
|
die(`Unknown option: ${arg}`);
|
|
}
|
|
}
|
|
return opts;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function die(msg) {
|
|
console.error(`Error: ${msg}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
/**
|
|
* Search the repo for a file whose path ends with the given suffix.
|
|
* Tries progressively shorter trailing-path suffixes until a unique match
|
|
* is found, then returns the absolute path.
|
|
*/
|
|
async function findInRepo(repoRoot, suffix) {
|
|
// Normalise to forward slashes
|
|
const parts = suffix.replace(/\\/g, "/").split("/").filter(Boolean);
|
|
// Try from the longest suffix down to just the filename
|
|
for (let take = parts.length; take >= 1; take--) {
|
|
const pattern = "**/" + parts.slice(-take).join("/");
|
|
const matches = [];
|
|
try {
|
|
for await (const f of glob(pattern, {
|
|
cwd: repoRoot,
|
|
exclude: (n) => n === "node_modules" || n === ".git"
|
|
})) {
|
|
matches.push(path.join(repoRoot, f));
|
|
}
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (matches.length === 1) return matches[0];
|
|
if (matches.length > 1) {
|
|
// If there are several, prefer the one with the most suffix components in common
|
|
matches.sort(
|
|
(a, b) =>
|
|
b.replace(/\\/g, "/").split("/").length -
|
|
a.replace(/\\/g, "/").split("/").length
|
|
);
|
|
return matches[0];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Cache to avoid re-searching the same suffix
|
|
const repoSearchCache = new Map();
|
|
|
|
async function resolveSourcePathAsync(source, mapUrl, repoRoot) {
|
|
if (!repoRoot) return source;
|
|
const cached = repoSearchCache.get(source);
|
|
if (cached !== undefined) return cached;
|
|
|
|
let pathname;
|
|
try {
|
|
pathname = new URL(source, mapUrl).pathname.replace(/^\//, "");
|
|
} catch {
|
|
repoSearchCache.set(source, source);
|
|
return source;
|
|
}
|
|
|
|
// Try exact path first (and common dist→src substitutions)
|
|
const candidates = [
|
|
path.join(repoRoot, pathname),
|
|
path.join(repoRoot, pathname.replace(/\/dist\/esm\//, "/src/")),
|
|
path.join(repoRoot, pathname.replace(/\/dist\//, "/src/"))
|
|
];
|
|
for (const c of candidates) {
|
|
if (existsSync(c)) {
|
|
repoSearchCache.set(source, c);
|
|
return c;
|
|
}
|
|
}
|
|
|
|
// Fall back: fuzzy search in the repo
|
|
const found = await findInRepo(repoRoot, pathname);
|
|
const result = found ?? path.join(repoRoot, pathname);
|
|
repoSearchCache.set(source, result);
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Walk up the directory tree from `startDir` looking for a `.git` folder.
|
|
* Returns the repo root, or null if not found.
|
|
*/
|
|
function findRepoRoot(startDir) {
|
|
let dir = startDir;
|
|
while (true) {
|
|
if (existsSync(path.join(dir, ".git"))) return dir;
|
|
const parent = path.dirname(dir);
|
|
if (parent === dir) return null; // reached filesystem root
|
|
dir = parent;
|
|
}
|
|
}
|
|
|
|
function printHelp() {
|
|
const script = path.relative(process.cwd(), fileURLToPath(import.meta.url));
|
|
console.log(
|
|
`
|
|
Usage: node ${script} [options] [stack-trace-file]
|
|
|
|
If no file is given the stack trace is read from stdin.
|
|
The trace may use "+" as a space (URL-encoded form).
|
|
|
|
Options:
|
|
--base-url <url> Base URL for remote .map files
|
|
(default: https://app.notesnook.com/assets/)
|
|
--local <dir> Look for .map files in this local directory first
|
|
--cache-dir <dir> On-disk cache for downloaded source maps
|
|
(default: ~/.cache/notesnook/sourcemaps)
|
|
--no-cache Disable on-disk caching
|
|
--context <n> Lines of source context per frame (default: 2, 0 to disable)
|
|
--help Show this help
|
|
`.trim()
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Stack frame parsing
|
|
//
|
|
// Handles frames like:
|
|
// at XB (https://example.com/assets/app-HASH.js:136:27466)
|
|
// at https://example.com/assets/app-HASH.js:12:345 (anonymous)
|
|
// at+XB+(https://example.com/assets/app-HASH.js:136:27466) (URL-encoded)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const FRAME_RE =
|
|
/at\s+(?:async\s+)?(\S+)\s+\(?(https?:\/\/[^\s):]+):(\d+):(\d+)\)?|at\s+(?:async\s+)?(https?:\/\/[^\s):]+):(\d+):(\d+)/g;
|
|
|
|
function parseStackTrace(raw) {
|
|
// Replace URL-encoded + with spaces so the regex works uniformly
|
|
const text = raw.replace(/\+/g, " ");
|
|
const lines = text.split(/\r?\n/);
|
|
const frames = [];
|
|
|
|
for (const line of lines) {
|
|
FRAME_RE.lastIndex = 0;
|
|
const m = FRAME_RE.exec(line);
|
|
if (!m) {
|
|
frames.push({ raw: line, parsed: false });
|
|
continue;
|
|
}
|
|
|
|
if (m[2]) {
|
|
// "at symbol (url:line:col)"
|
|
frames.push({
|
|
raw: line,
|
|
parsed: true,
|
|
symbol: m[1],
|
|
url: m[2],
|
|
line: parseInt(m[3], 10),
|
|
col: parseInt(m[4], 10)
|
|
});
|
|
} else {
|
|
// "at url:line:col"
|
|
frames.push({
|
|
raw: line,
|
|
parsed: true,
|
|
symbol: null,
|
|
url: m[5],
|
|
line: parseInt(m[6], 10),
|
|
col: parseInt(m[7], 10)
|
|
});
|
|
}
|
|
}
|
|
|
|
return frames;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Source map fetching / caching
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function fetchRemote(url) {
|
|
return new Promise((resolve, reject) => {
|
|
const client = url.startsWith("https://") ? https : http;
|
|
client
|
|
.get(url, (res) => {
|
|
if (res.statusCode === 301 || res.statusCode === 302) {
|
|
return fetchRemote(res.headers.location).then(resolve).catch(reject);
|
|
}
|
|
if (res.statusCode !== 200) {
|
|
reject(new Error(`HTTP ${res.statusCode} for ${url}`));
|
|
res.resume();
|
|
return;
|
|
}
|
|
const chunks = [];
|
|
res.on("data", (c) => chunks.push(c));
|
|
res.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
})
|
|
.on("error", reject);
|
|
});
|
|
}
|
|
|
|
async function loadSourceMap(filename, opts) {
|
|
// 1. Try local directory first
|
|
if (opts.localDir) {
|
|
const local = path.join(opts.localDir, filename);
|
|
if (existsSync(local)) {
|
|
process.stderr.write(`Using local map: ${local}\n`);
|
|
return readFileSync(local, "utf8");
|
|
}
|
|
}
|
|
|
|
// 2. Try on-disk cache
|
|
if (!opts.noCache) {
|
|
mkdirSync(opts.cacheDir, { recursive: true });
|
|
const cached = path.join(opts.cacheDir, filename);
|
|
if (existsSync(cached)) {
|
|
process.stderr.write(`Cache hit: ${filename}\n`);
|
|
return readFileSync(cached, "utf8");
|
|
}
|
|
}
|
|
|
|
// 3. Fetch remotely
|
|
const url = opts.baseUrl + filename;
|
|
process.stderr.write(`Fetching ${url} ...\n`);
|
|
const body = await fetchRemote(url);
|
|
|
|
// 4. Persist to cache
|
|
if (!opts.noCache) {
|
|
const cached = path.join(opts.cacheDir, filename);
|
|
writeFileSync(cached, body);
|
|
}
|
|
|
|
return body;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Symbol demangling helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* For a dotted minified symbol like "Bs.exec", walk right-to-left through
|
|
* each part and try to resolve each one via the source map by estimating the
|
|
* column offset of that identifier in the minified source.
|
|
*
|
|
* V8 reports the column of the method name (rightmost part) in the call
|
|
* expression, so we subtract (len(part) + 1) per step to back-track to each
|
|
* preceding identifier.
|
|
*/
|
|
function tryDemangleDotted(consumer, generatedLine, generatedCol, symbol) {
|
|
const parts = symbol.split(".");
|
|
if (parts.length <= 1) return null;
|
|
|
|
const resolved = [...parts];
|
|
let col = generatedCol;
|
|
|
|
for (let i = parts.length - 1; i >= 0; i--) {
|
|
const pos = consumer.originalPositionFor({ line: generatedLine, column: col });
|
|
if (pos.name) resolved[i] = pos.name;
|
|
// Step back past this identifier and the dot that precedes it
|
|
col -= parts[i].length + 1;
|
|
if (col < 0) break;
|
|
}
|
|
|
|
const changed = resolved.some((p, i) => p !== parts[i]);
|
|
return changed ? resolved.join(".") : null;
|
|
}
|
|
|
|
/**
|
|
* Return lines of original source context around (line, col) using the
|
|
* sourcesContent embedded in the source map. Returns null if unavailable.
|
|
*/
|
|
function getSourceContext(consumer, source, line, col, contextLines) {
|
|
if (contextLines <= 0) return null;
|
|
let content;
|
|
try {
|
|
content = consumer.sourceContentFor(source, /*returnNullOnMissing=*/ true);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (!content) return null;
|
|
|
|
const allLines = content.split("\n");
|
|
const first = Math.max(0, line - 1 - contextLines);
|
|
const last = Math.min(allLines.length - 1, line - 1 + contextLines);
|
|
const numWidth = String(last + 1).length;
|
|
|
|
return allLines.slice(first, last + 1).map((text, i) => ({
|
|
lineNum: first + i + 1,
|
|
text,
|
|
isTarget: first + i + 1 === line,
|
|
col: first + i + 1 === line ? col : null,
|
|
numWidth
|
|
}));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lazy source-map consumer loader (avoids hard dependency; works with both
|
|
// the CommonJS "source-map" package and the WASM "source-map-js" package).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let SourceMapConsumer;
|
|
|
|
async function getConsumer(rawJson) {
|
|
if (!SourceMapConsumer) {
|
|
try {
|
|
const mod = await import("source-map");
|
|
SourceMapConsumer = mod.SourceMapConsumer;
|
|
} catch {
|
|
try {
|
|
const mod = await import("source-map-js");
|
|
SourceMapConsumer = mod.SourceMapConsumer;
|
|
} catch {
|
|
die(
|
|
'Neither "source-map" nor "source-map-js" is installed.\n' +
|
|
"Run: npm install -g source-map or npm install -g source-map-js"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return new SourceMapConsumer(JSON.parse(rawJson));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function main() {
|
|
const opts = parseArgs(argv);
|
|
|
|
if (opts.help) {
|
|
printHelp();
|
|
process.exit(0);
|
|
}
|
|
|
|
// Read the raw stack trace
|
|
let raw;
|
|
if (opts.inputFile) {
|
|
if (!existsSync(opts.inputFile)) die(`File not found: ${opts.inputFile}`);
|
|
raw = readFileSync(opts.inputFile, "utf8");
|
|
} else if (!process.stdin.isTTY) {
|
|
raw = readFileSync("/dev/stdin", "utf8");
|
|
} else {
|
|
die("No input: provide a file argument or pipe a stack trace to stdin.");
|
|
}
|
|
|
|
const frames = parseStackTrace(raw);
|
|
|
|
// Collect unique bundle filenames that need a source map
|
|
const bundles = new Set(
|
|
frames.filter((f) => f.parsed).map((f) => path.basename(f.url) + ".map")
|
|
);
|
|
|
|
// Load all needed source maps in parallel
|
|
process.stderr.write("\n");
|
|
// Determine the repo root once so we can produce clickable absolute paths.
|
|
const repoRoot = findRepoRoot(process.cwd());
|
|
|
|
const consumers = {}; // mapFile -> { consumer, mapUrl }
|
|
await Promise.all(
|
|
[...bundles].map(async (mapFile) => {
|
|
try {
|
|
const raw = await loadSourceMap(mapFile, opts);
|
|
const mapUrl = opts.baseUrl + mapFile;
|
|
consumers[mapFile] = { consumer: await getConsumer(raw), mapUrl };
|
|
} catch (e) {
|
|
process.stderr.write(
|
|
`Warning: could not load ${mapFile}: ${e.message}\n`
|
|
);
|
|
}
|
|
})
|
|
);
|
|
process.stderr.write("\n");
|
|
|
|
// Resolve and print
|
|
for (const frame of frames) {
|
|
if (!frame.parsed) {
|
|
console.log(frame.raw);
|
|
continue;
|
|
}
|
|
|
|
const mapFile = path.basename(frame.url) + ".map";
|
|
const entry = consumers[mapFile];
|
|
|
|
if (!entry) {
|
|
console.log(
|
|
` at ${frame.symbol ?? "<anonymous>"} (${frame.url}:${frame.line}:${
|
|
frame.col
|
|
}) [no source map]`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const { consumer, mapUrl } = entry;
|
|
const pos = consumer.originalPositionFor({
|
|
line: frame.line,
|
|
column: frame.col
|
|
});
|
|
|
|
if (!pos.source) {
|
|
// Could not map — fall back to minified info
|
|
console.log(
|
|
` at ${frame.symbol ?? "<anonymous>"} (${frame.url}:${frame.line}:${
|
|
frame.col
|
|
}) [unmapped]`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
// Resolve the best available symbol name:
|
|
// 1. name from the source map at this exact position
|
|
// 2. dotted-symbol walk (e.g. "Bs.exec" → "SqliteWorker.exec")
|
|
// 3. original minified symbol as fallback
|
|
const minifiedSymbol = frame.symbol ?? "<anonymous>";
|
|
const name =
|
|
(minifiedSymbol.includes(".")
|
|
? tryDemangleDotted(consumer, frame.line, frame.col, minifiedSymbol)
|
|
: null) ??
|
|
pos.name ??
|
|
minifiedSymbol;
|
|
|
|
// Resolve to an absolute (clickable) path.
|
|
const src = await resolveSourcePathAsync(pos.source, mapUrl, repoRoot);
|
|
console.log(` at ${name} (${src}:${pos.line}:${pos.column})`);
|
|
|
|
// Source context
|
|
const ctx = getSourceContext(consumer, pos.source, pos.line, pos.column, opts.context);
|
|
if (ctx) {
|
|
for (const l of ctx) {
|
|
const gutter = l.isTarget ? ">" : " ";
|
|
const num = String(l.lineNum).padStart(l.numWidth);
|
|
console.log(` ${gutter} ${num} | ${l.text}`);
|
|
}
|
|
console.log();
|
|
}
|
|
}
|
|
|
|
// Clean up WASM consumers if applicable
|
|
for (const { consumer: c } of Object.values(consumers)) {
|
|
if (typeof c.destroy === "function") c.destroy();
|
|
}
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error(e);
|
|
process.exit(1);
|
|
});
|