( app: App, vaultRelativePath: string, )
| 48 | } |
| 49 | |
| 50 | export async function assertWriteStaysInVault( |
| 51 | app: App, |
| 52 | vaultRelativePath: string, |
| 53 | ): Promise<void> { |
| 54 | const adapter = app.vault.adapter; |
| 55 | // `typeof` guard: FileSystemAdapter is absent on mobile and in the test stub — |
| 56 | // `instanceof undefined` would throw, so check the symbol is a constructor first. |
| 57 | if (typeof FileSystemAdapter !== "function" || !(adapter instanceof FileSystemAdapter)) { |
| 58 | return; // mobile / no real FS — string sanitization is the only guard there |
| 59 | } |
| 60 | |
| 61 | const fs = nodeRequire<NodeFs>("fs"); |
| 62 | const path = nodeRequire<NodePath>("path"); |
| 63 | if (!fs?.promises || !path) return; // can't verify — fail open only when fs is unavailable |
| 64 | |
| 65 | const realBase = await fs.promises.realpath(adapter.getBasePath()); |
| 66 | const targetFull = adapter.getFullPath(vaultRelativePath); |
| 67 | |
| 68 | // Walk up to the nearest path that actually exists; resolve ITS realpath. Any |
| 69 | // symlink in the existing chain (pointing outside the vault) surfaces here. The |
| 70 | // not-yet-created tail is plain, already-sanitized names, so it cannot escape. |
| 71 | let probe = targetFull; |
| 72 | while (!exists(fs, probe)) { |
| 73 | const parent = path.dirname(probe); |
| 74 | if (parent === probe) break; |
| 75 | probe = parent; |
| 76 | } |
| 77 | |
| 78 | let realTarget: string; |
| 79 | try { |
| 80 | realTarget = await fs.promises.realpath(probe); |
| 81 | } catch { |
| 82 | // `probe` exists (lstatSync above) yet cannot be resolved: it is, or passes |
| 83 | // through, a DANGLING symlink whose ultimate target does not exist. A write |
| 84 | // still FOLLOWS that symlink and can land outside the vault, and we cannot |
| 85 | // prove where — so fail CLOSED. (On Linux/glibc realpath throws ENOENT here; |
| 86 | // the previous `.catch(() => probe)` fallback trusted the unresolved in-vault |
| 87 | // path and let the escape through.) |
| 88 | throw new VaultWriteEscapeError( |
| 89 | `Refusing to write to "${vaultRelativePath}": it resolves through an unresolvable (dangling) symlink.`, |
| 90 | ); |
| 91 | } |
| 92 | const rel = path.relative(realBase, realTarget); |
| 93 | const escapes = rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel); |
| 94 | if (escapes) { |
| 95 | throw new VaultWriteEscapeError( |
| 96 | `Refusing to write to "${vaultRelativePath}": it resolves (via a symlink) outside the vault.`, |
| 97 | ); |
| 98 | } |
| 99 | |
| 100 | // Reject a target that RESOLVES into a dot/config directory (.obsidian, .git, |
| 101 | // .trash, ...) even though it stays inside the vault. The lexical per-segment |
| 102 | // dot-floor only sees the LITERAL destination, so a pre-existing in-vault |
| 103 | // symlink like `safe -> .obsidian/plugins/pkg` would otherwise smuggle an |
| 104 | // untrusted "safe/main.js" write into a config/executable directory. Checking |
| 105 | // the realpath-resolved segments closes that symlink-mediated config-dir drop. |
| 106 | const resolvedEscapesIntoConfigDir = rel |
| 107 | .split(path.sep) |
no test coverage detected