isomorphic-git 1.38.4 → 1.38.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.cjs CHANGED
@@ -1511,6 +1511,18 @@ function compareRefNames(a, b) {
1511
1511
  * This code for `path.join` is directly copied from @zenfs/core/path for bundle size improvements.
1512
1512
  * SPDX-License-Identifier: LGPL-3.0-or-later
1513
1513
  * Copyright (c) James Prevett and other ZenFS contributors.
1514
+ *
1515
+ * Windows support added:
1516
+ * - Backslashes are normalised to forward slashes before processing.
1517
+ * - Drive-letter prefixes (e.g. "C:") are detected and preserved through
1518
+ * normalisation, so absolute Windows paths are handled correctly.
1519
+ * - An absolute argument passed to join() resets the accumulated path,
1520
+ * matching Node behaviour and handling worktree gitdir paths properly.
1521
+ *
1522
+ * Limitation: UNC paths (e.g. \\server\share) are not supported. The leading
1523
+ * backslashes are normalised to forward slashes and then collapsed by
1524
+ * normalizeString, losing the UNC root. Git on Windows works with
1525
+ * drive-letter paths, so this is not expected to be a practical issue.
1514
1526
  */
1515
1527
 
1516
1528
  function normalizeString(path, aar) {
@@ -1574,29 +1586,67 @@ function normalizeString(path, aar) {
1574
1586
  return res
1575
1587
  }
1576
1588
 
1589
+ // Returns the Windows drive prefix ("C:") if present, otherwise null.
1590
+ function getWindowsDrivePrefix(path) {
1591
+ if (path.length >= 2 && /^[a-zA-Z]:/.test(path)) {
1592
+ return path.slice(0, 2) // e.g. "C:"
1593
+ }
1594
+ return null
1595
+ }
1596
+
1577
1597
  function normalize(path) {
1578
1598
  if (!path.length) return '.'
1579
1599
 
1580
- const isAbsolute = path[0] === '/';
1600
+ // Normalise backslashes to forward slashes before any other processing.
1601
+ path = path.replace(/\\/g, '/');
1602
+
1603
+ const drivePrefix = getWindowsDrivePrefix(path);
1604
+ // isAbsolute: Unix root ('/foo') OR Windows drive+slash ('C:/foo').
1605
+ const isAbsolute =
1606
+ path[0] === '/' || (drivePrefix !== null && path[2] === '/');
1581
1607
  const trailingSeparator = path.at(-1) === '/';
1582
1608
 
1583
- path = normalizeString(path, !isAbsolute);
1609
+ // Strip the drive prefix before feeding into normalizeString so that the
1610
+ // core algorithm only ever sees a plain POSIX-style string.
1611
+ const pathBody = drivePrefix ? path.slice(2) : path;
1612
+
1613
+ let normalized = normalizeString(pathBody, !isAbsolute);
1584
1614
 
1585
- if (!path.length) {
1586
- if (isAbsolute) return '/'
1587
- return trailingSeparator ? './' : '.'
1615
+ if (!normalized.length) {
1616
+ const root = drivePrefix
1617
+ ? isAbsolute
1618
+ ? drivePrefix + '/'
1619
+ : drivePrefix
1620
+ : isAbsolute
1621
+ ? '/'
1622
+ : '.';
1623
+ return trailingSeparator && !isAbsolute ? root + '/' : root
1588
1624
  }
1589
- if (trailingSeparator) path += '/';
1625
+ if (trailingSeparator) normalized += '/';
1590
1626
 
1591
- return isAbsolute ? `/${path}` : path
1627
+ if (drivePrefix) {
1628
+ return isAbsolute
1629
+ ? `${drivePrefix}/${normalized}`
1630
+ : `${drivePrefix}${normalized}`
1631
+ }
1632
+ return isAbsolute ? `/${normalized}` : normalized
1592
1633
  }
1593
1634
 
1594
1635
  function join(...args) {
1595
1636
  if (args.length === 0) return '.'
1596
1637
  let joined;
1597
1638
  for (let i = 0; i < args.length; ++i) {
1598
- const arg = args[i];
1599
- if (arg.length > 0) {
1639
+ // Normalise separators before processing.
1640
+ const arg = args[i].replace(/\\/g, '/');
1641
+ if (arg.length === 0) continue
1642
+
1643
+ // A Windows drive-letter path (e.g. "C:/worktrees/foo") cannot be
1644
+ // meaningfully appended to any base, so it resets the accumulator.
1645
+ // Unix absolute paths (leading '/') are NOT reset here — that would be
1646
+ // path.resolve() semantics; path.join('foo', '/bar') must yield 'foo/bar'.
1647
+ if (/^[a-zA-Z]:\//.test(arg)) {
1648
+ joined = arg;
1649
+ } else {
1600
1650
  if (joined === undefined) joined = arg;
1601
1651
  else joined += '/' + arg;
1602
1652
  }
@@ -2181,7 +2231,13 @@ class GitRefManager {
2181
2231
  * @param {number} [args.depth = undefined] - The maximum depth to resolve symbolic refs.
2182
2232
  * @returns {Promise<string>} - The resolved object ID.
2183
2233
  */
2184
- static async resolve({ fs, gitdir, ref, depth = undefined }) {
2234
+ static async resolve({
2235
+ fs,
2236
+ gitdir,
2237
+ ref,
2238
+ depth = undefined,
2239
+ visited = new Set(),
2240
+ }) {
2185
2241
  if (depth !== undefined) {
2186
2242
  depth--;
2187
2243
  if (depth === -1) {
@@ -2192,7 +2248,7 @@ class GitRefManager {
2192
2248
  // Is it a ref pointer?
2193
2249
  if (ref.startsWith('ref: ')) {
2194
2250
  ref = ref.slice('ref: '.length);
2195
- return GitRefManager.resolve({ fs, gitdir, ref, depth })
2251
+ return GitRefManager.resolve({ fs, gitdir, ref, depth, visited })
2196
2252
  }
2197
2253
  // Is it a complete and valid SHA?
2198
2254
  if (ref.length === 40 && /[0-9a-f]{40}/.test(ref)) {
@@ -2211,7 +2267,21 @@ class GitRefManager {
2211
2267
  packedMap.get(ref)
2212
2268
  );
2213
2269
  if (sha) {
2214
- return GitRefManager.resolve({ fs, gitdir, ref: sha.trim(), depth })
2270
+ // Guard against circular symbolic references (e.g. a -> b -> a). Without
2271
+ // tracking visited refs this recursion would not terminate.
2272
+ if (visited.has(ref)) {
2273
+ throw new InternalError(
2274
+ `Circular reference detected while resolving ref "${ref}"`
2275
+ )
2276
+ }
2277
+ visited.add(ref);
2278
+ return GitRefManager.resolve({
2279
+ fs,
2280
+ gitdir,
2281
+ ref: sha.trim(),
2282
+ depth,
2283
+ visited,
2284
+ })
2215
2285
  }
2216
2286
  }
2217
2287
  // Do we give up?
@@ -2467,8 +2537,25 @@ function parseBuffer(buffer) {
2467
2537
  const type = mode2type$1(mode);
2468
2538
  const path = buffer.slice(space + 1, nullchar).toString('utf8');
2469
2539
 
2470
- // Prevent malicious git repos from writing to "..\foo" on clone etc
2471
- if (path.includes('\\') || path.includes('/')) {
2540
+ // Reject reserved entry names, matching git's verify_path(): path separators,
2541
+ // "." and ".." (which resolve outside the working directory), and ".git" (which git
2542
+ // disallows as a path component). Checks mirror git's is_ntfs_dotgit() and
2543
+ // is_hfs_dotgit(): case-insensitive, trailing dots/spaces stripped (NTFS),
2544
+ // NTFS 8.3 short name aliases (git~1..git~9), and HFS+ ignorable Unicode
2545
+ // characters stripped (zero-width joiners, directional marks, BOM, etc.).
2546
+ const hfsClean = path.replace(
2547
+ /[\u200C-\u200F\u202A-\u202E\u206A-\u206F\uFEFF]/g,
2548
+ ''
2549
+ );
2550
+ const normalized = hfsClean.toLowerCase().replace(/[. ]+$/, '');
2551
+ if (
2552
+ path.includes('\\') ||
2553
+ path.includes('/') ||
2554
+ hfsClean === '.' ||
2555
+ hfsClean === '..' ||
2556
+ normalized === '.git' ||
2557
+ /^\.?git~[1-9]$/.test(normalized)
2558
+ ) {
2472
2559
  throw new UnsafeFilepathError(path)
2473
2560
  }
2474
2561
 
@@ -2642,21 +2729,26 @@ function applyDelta(delta, source) {
2642
2729
  if (firstOp.byteLength === targetSize) {
2643
2730
  target = firstOp;
2644
2731
  } else {
2645
- // Otherwise, allocate a fresh buffer and slices
2646
- target = Buffer.alloc(targetSize);
2647
- const writer = new BufferCursor(target);
2648
- writer.copy(firstOp);
2732
+ // Build the result from the delta ops instead of pre-allocating `targetSize`.
2733
+ // `targetSize` comes from the delta header and may not match the actual op output,
2734
+ // so allocating it up front can reserve far more memory than the ops produce.
2735
+ // Building from the ops bounds memory to the real output size; the size check below
2736
+ // still validates the total before concatenating.
2737
+ const chunks = [firstOp];
2738
+ let tell = firstOp.byteLength;
2649
2739
 
2650
2740
  while (!reader.eof()) {
2651
- writer.copy(readOp(reader, source));
2741
+ const op = readOp(reader, source);
2742
+ chunks.push(op);
2743
+ tell += op.byteLength;
2652
2744
  }
2653
2745
 
2654
- const tell = writer.tell();
2655
2746
  if (targetSize !== tell) {
2656
2747
  throw new InternalError(
2657
2748
  `applyDelta expected target buffer to be ${targetSize} bytes but the resulting buffer was ${tell} bytes`
2658
2749
  )
2659
2750
  }
2751
+ target = Buffer.concat(chunks, targetSize);
2660
2752
  }
2661
2753
  return target
2662
2754
  }
@@ -6805,6 +6897,30 @@ const worthWalking = (filepath, root) => {
6805
6897
 
6806
6898
  // @ts-check
6807
6899
 
6900
+ /**
6901
+ * Throw if any leading directory component of `fullpath` (relative to `dir`) is a symbolic
6902
+ * link. git does not follow symlinks in the leading path when writing working-tree files;
6903
+ * matching that avoids writing through a symlinked parent into a location outside `dir`.
6904
+ *
6905
+ * @param {import('../models/FileSystem.js').FileSystem} fs
6906
+ * @param {string} dir
6907
+ * @param {string} fullpath
6908
+ */
6909
+ async function assertNoSymlinkInLeadingPath(fs, dir, fullpath) {
6910
+ const parts = fullpath.split('/');
6911
+ parts.pop(); // the final segment is the entry being written, not a leading dir
6912
+ let current = dir;
6913
+ for (const part of parts) {
6914
+ if (part === '' || part === '.') continue
6915
+ current = `${current}/${part}`;
6916
+ const stats = await fs.lstat(current);
6917
+ // lstat returns null when the path doesn't exist yet (nothing to traverse).
6918
+ if (stats && stats.isSymbolicLink()) {
6919
+ throw new UnsafeFilepathError(fullpath)
6920
+ }
6921
+ }
6922
+ }
6923
+
6808
6924
  /**
6809
6925
  * @param {object} args
6810
6926
  * @param {import('../models/FileSystem.js').FileSystem} args.fs
@@ -7011,6 +7127,7 @@ async function _checkout({
7011
7127
  .filter(([method]) => method === 'mkdir' || method === 'mkdir-index')
7012
7128
  .map(async function ([_, fullpath]) {
7013
7129
  const filepath = `${dir}/${fullpath}`;
7130
+ await assertNoSymlinkInLeadingPath(fs, dir, fullpath);
7014
7131
  await fs.mkdir(filepath);
7015
7132
  if (onProgress) {
7016
7133
  await onProgress({
@@ -7080,6 +7197,10 @@ async function _checkout({
7080
7197
  .map(async function ([method, fullpath, oid, mode, chmod]) {
7081
7198
  const filepath = `${dir}/${fullpath}`;
7082
7199
  if (method !== 'create-index' && method !== 'mkdir-index') {
7200
+ // Don't write through a symlinked leading path: a parent component that
7201
+ // is a symlink could otherwise redirect the write outside the working
7202
+ // tree. git applies the same check (it does not follow symlinks here).
7203
+ await assertNoSymlinkInLeadingPath(fs, dir, fullpath);
7083
7204
  const { object } = await _readObject({
7084
7205
  fs,
7085
7206
  cache,
@@ -7486,6 +7607,7 @@ async function updateWorkingDir(
7486
7607
  ) {
7487
7608
  const filepath = `${dir}/${fullpath}`;
7488
7609
  if (method !== 'create-index' && method !== 'mkdir-index') {
7610
+ await assertNoSymlinkInLeadingPath(fs, dir, fullpath);
7489
7611
  const { object } = await _readObject({ fs, cache, gitdir, oid });
7490
7612
  if (chmod) {
7491
7613
  await fs.rm(filepath);
@@ -9360,8 +9482,8 @@ function filterCapabilities(server, client) {
9360
9482
 
9361
9483
  const pkg = {
9362
9484
  name: 'isomorphic-git',
9363
- version: '1.38.4',
9364
- agent: 'git/isomorphic-git@1.38.4',
9485
+ version: '1.38.6',
9486
+ agent: 'git/isomorphic-git@1.38.6',
9365
9487
  };
9366
9488
 
9367
9489
  class FIFO {
package/index.js CHANGED
@@ -1504,6 +1504,18 @@ function compareRefNames(a, b) {
1504
1504
  * This code for `path.join` is directly copied from @zenfs/core/path for bundle size improvements.
1505
1505
  * SPDX-License-Identifier: LGPL-3.0-or-later
1506
1506
  * Copyright (c) James Prevett and other ZenFS contributors.
1507
+ *
1508
+ * Windows support added:
1509
+ * - Backslashes are normalised to forward slashes before processing.
1510
+ * - Drive-letter prefixes (e.g. "C:") are detected and preserved through
1511
+ * normalisation, so absolute Windows paths are handled correctly.
1512
+ * - An absolute argument passed to join() resets the accumulated path,
1513
+ * matching Node behaviour and handling worktree gitdir paths properly.
1514
+ *
1515
+ * Limitation: UNC paths (e.g. \\server\share) are not supported. The leading
1516
+ * backslashes are normalised to forward slashes and then collapsed by
1517
+ * normalizeString, losing the UNC root. Git on Windows works with
1518
+ * drive-letter paths, so this is not expected to be a practical issue.
1507
1519
  */
1508
1520
 
1509
1521
  function normalizeString(path, aar) {
@@ -1567,29 +1579,67 @@ function normalizeString(path, aar) {
1567
1579
  return res
1568
1580
  }
1569
1581
 
1582
+ // Returns the Windows drive prefix ("C:") if present, otherwise null.
1583
+ function getWindowsDrivePrefix(path) {
1584
+ if (path.length >= 2 && /^[a-zA-Z]:/.test(path)) {
1585
+ return path.slice(0, 2) // e.g. "C:"
1586
+ }
1587
+ return null
1588
+ }
1589
+
1570
1590
  function normalize(path) {
1571
1591
  if (!path.length) return '.'
1572
1592
 
1573
- const isAbsolute = path[0] === '/';
1593
+ // Normalise backslashes to forward slashes before any other processing.
1594
+ path = path.replace(/\\/g, '/');
1595
+
1596
+ const drivePrefix = getWindowsDrivePrefix(path);
1597
+ // isAbsolute: Unix root ('/foo') OR Windows drive+slash ('C:/foo').
1598
+ const isAbsolute =
1599
+ path[0] === '/' || (drivePrefix !== null && path[2] === '/');
1574
1600
  const trailingSeparator = path.at(-1) === '/';
1575
1601
 
1576
- path = normalizeString(path, !isAbsolute);
1602
+ // Strip the drive prefix before feeding into normalizeString so that the
1603
+ // core algorithm only ever sees a plain POSIX-style string.
1604
+ const pathBody = drivePrefix ? path.slice(2) : path;
1605
+
1606
+ let normalized = normalizeString(pathBody, !isAbsolute);
1577
1607
 
1578
- if (!path.length) {
1579
- if (isAbsolute) return '/'
1580
- return trailingSeparator ? './' : '.'
1608
+ if (!normalized.length) {
1609
+ const root = drivePrefix
1610
+ ? isAbsolute
1611
+ ? drivePrefix + '/'
1612
+ : drivePrefix
1613
+ : isAbsolute
1614
+ ? '/'
1615
+ : '.';
1616
+ return trailingSeparator && !isAbsolute ? root + '/' : root
1581
1617
  }
1582
- if (trailingSeparator) path += '/';
1618
+ if (trailingSeparator) normalized += '/';
1583
1619
 
1584
- return isAbsolute ? `/${path}` : path
1620
+ if (drivePrefix) {
1621
+ return isAbsolute
1622
+ ? `${drivePrefix}/${normalized}`
1623
+ : `${drivePrefix}${normalized}`
1624
+ }
1625
+ return isAbsolute ? `/${normalized}` : normalized
1585
1626
  }
1586
1627
 
1587
1628
  function join(...args) {
1588
1629
  if (args.length === 0) return '.'
1589
1630
  let joined;
1590
1631
  for (let i = 0; i < args.length; ++i) {
1591
- const arg = args[i];
1592
- if (arg.length > 0) {
1632
+ // Normalise separators before processing.
1633
+ const arg = args[i].replace(/\\/g, '/');
1634
+ if (arg.length === 0) continue
1635
+
1636
+ // A Windows drive-letter path (e.g. "C:/worktrees/foo") cannot be
1637
+ // meaningfully appended to any base, so it resets the accumulator.
1638
+ // Unix absolute paths (leading '/') are NOT reset here — that would be
1639
+ // path.resolve() semantics; path.join('foo', '/bar') must yield 'foo/bar'.
1640
+ if (/^[a-zA-Z]:\//.test(arg)) {
1641
+ joined = arg;
1642
+ } else {
1593
1643
  if (joined === undefined) joined = arg;
1594
1644
  else joined += '/' + arg;
1595
1645
  }
@@ -2174,7 +2224,13 @@ class GitRefManager {
2174
2224
  * @param {number} [args.depth = undefined] - The maximum depth to resolve symbolic refs.
2175
2225
  * @returns {Promise<string>} - The resolved object ID.
2176
2226
  */
2177
- static async resolve({ fs, gitdir, ref, depth = undefined }) {
2227
+ static async resolve({
2228
+ fs,
2229
+ gitdir,
2230
+ ref,
2231
+ depth = undefined,
2232
+ visited = new Set(),
2233
+ }) {
2178
2234
  if (depth !== undefined) {
2179
2235
  depth--;
2180
2236
  if (depth === -1) {
@@ -2185,7 +2241,7 @@ class GitRefManager {
2185
2241
  // Is it a ref pointer?
2186
2242
  if (ref.startsWith('ref: ')) {
2187
2243
  ref = ref.slice('ref: '.length);
2188
- return GitRefManager.resolve({ fs, gitdir, ref, depth })
2244
+ return GitRefManager.resolve({ fs, gitdir, ref, depth, visited })
2189
2245
  }
2190
2246
  // Is it a complete and valid SHA?
2191
2247
  if (ref.length === 40 && /[0-9a-f]{40}/.test(ref)) {
@@ -2204,7 +2260,21 @@ class GitRefManager {
2204
2260
  packedMap.get(ref)
2205
2261
  );
2206
2262
  if (sha) {
2207
- return GitRefManager.resolve({ fs, gitdir, ref: sha.trim(), depth })
2263
+ // Guard against circular symbolic references (e.g. a -> b -> a). Without
2264
+ // tracking visited refs this recursion would not terminate.
2265
+ if (visited.has(ref)) {
2266
+ throw new InternalError(
2267
+ `Circular reference detected while resolving ref "${ref}"`
2268
+ )
2269
+ }
2270
+ visited.add(ref);
2271
+ return GitRefManager.resolve({
2272
+ fs,
2273
+ gitdir,
2274
+ ref: sha.trim(),
2275
+ depth,
2276
+ visited,
2277
+ })
2208
2278
  }
2209
2279
  }
2210
2280
  // Do we give up?
@@ -2460,8 +2530,25 @@ function parseBuffer(buffer) {
2460
2530
  const type = mode2type$1(mode);
2461
2531
  const path = buffer.slice(space + 1, nullchar).toString('utf8');
2462
2532
 
2463
- // Prevent malicious git repos from writing to "..\foo" on clone etc
2464
- if (path.includes('\\') || path.includes('/')) {
2533
+ // Reject reserved entry names, matching git's verify_path(): path separators,
2534
+ // "." and ".." (which resolve outside the working directory), and ".git" (which git
2535
+ // disallows as a path component). Checks mirror git's is_ntfs_dotgit() and
2536
+ // is_hfs_dotgit(): case-insensitive, trailing dots/spaces stripped (NTFS),
2537
+ // NTFS 8.3 short name aliases (git~1..git~9), and HFS+ ignorable Unicode
2538
+ // characters stripped (zero-width joiners, directional marks, BOM, etc.).
2539
+ const hfsClean = path.replace(
2540
+ /[\u200C-\u200F\u202A-\u202E\u206A-\u206F\uFEFF]/g,
2541
+ ''
2542
+ );
2543
+ const normalized = hfsClean.toLowerCase().replace(/[. ]+$/, '');
2544
+ if (
2545
+ path.includes('\\') ||
2546
+ path.includes('/') ||
2547
+ hfsClean === '.' ||
2548
+ hfsClean === '..' ||
2549
+ normalized === '.git' ||
2550
+ /^\.?git~[1-9]$/.test(normalized)
2551
+ ) {
2465
2552
  throw new UnsafeFilepathError(path)
2466
2553
  }
2467
2554
 
@@ -2635,21 +2722,26 @@ function applyDelta(delta, source) {
2635
2722
  if (firstOp.byteLength === targetSize) {
2636
2723
  target = firstOp;
2637
2724
  } else {
2638
- // Otherwise, allocate a fresh buffer and slices
2639
- target = Buffer.alloc(targetSize);
2640
- const writer = new BufferCursor(target);
2641
- writer.copy(firstOp);
2725
+ // Build the result from the delta ops instead of pre-allocating `targetSize`.
2726
+ // `targetSize` comes from the delta header and may not match the actual op output,
2727
+ // so allocating it up front can reserve far more memory than the ops produce.
2728
+ // Building from the ops bounds memory to the real output size; the size check below
2729
+ // still validates the total before concatenating.
2730
+ const chunks = [firstOp];
2731
+ let tell = firstOp.byteLength;
2642
2732
 
2643
2733
  while (!reader.eof()) {
2644
- writer.copy(readOp(reader, source));
2734
+ const op = readOp(reader, source);
2735
+ chunks.push(op);
2736
+ tell += op.byteLength;
2645
2737
  }
2646
2738
 
2647
- const tell = writer.tell();
2648
2739
  if (targetSize !== tell) {
2649
2740
  throw new InternalError(
2650
2741
  `applyDelta expected target buffer to be ${targetSize} bytes but the resulting buffer was ${tell} bytes`
2651
2742
  )
2652
2743
  }
2744
+ target = Buffer.concat(chunks, targetSize);
2653
2745
  }
2654
2746
  return target
2655
2747
  }
@@ -6792,6 +6884,30 @@ const worthWalking = (filepath, root) => {
6792
6884
 
6793
6885
  // @ts-check
6794
6886
 
6887
+ /**
6888
+ * Throw if any leading directory component of `fullpath` (relative to `dir`) is a symbolic
6889
+ * link. git does not follow symlinks in the leading path when writing working-tree files;
6890
+ * matching that avoids writing through a symlinked parent into a location outside `dir`.
6891
+ *
6892
+ * @param {import('../models/FileSystem.js').FileSystem} fs
6893
+ * @param {string} dir
6894
+ * @param {string} fullpath
6895
+ */
6896
+ async function assertNoSymlinkInLeadingPath(fs, dir, fullpath) {
6897
+ const parts = fullpath.split('/');
6898
+ parts.pop(); // the final segment is the entry being written, not a leading dir
6899
+ let current = dir;
6900
+ for (const part of parts) {
6901
+ if (part === '' || part === '.') continue
6902
+ current = `${current}/${part}`;
6903
+ const stats = await fs.lstat(current);
6904
+ // lstat returns null when the path doesn't exist yet (nothing to traverse).
6905
+ if (stats && stats.isSymbolicLink()) {
6906
+ throw new UnsafeFilepathError(fullpath)
6907
+ }
6908
+ }
6909
+ }
6910
+
6795
6911
  /**
6796
6912
  * @param {object} args
6797
6913
  * @param {import('../models/FileSystem.js').FileSystem} args.fs
@@ -6998,6 +7114,7 @@ async function _checkout({
6998
7114
  .filter(([method]) => method === 'mkdir' || method === 'mkdir-index')
6999
7115
  .map(async function ([_, fullpath]) {
7000
7116
  const filepath = `${dir}/${fullpath}`;
7117
+ await assertNoSymlinkInLeadingPath(fs, dir, fullpath);
7001
7118
  await fs.mkdir(filepath);
7002
7119
  if (onProgress) {
7003
7120
  await onProgress({
@@ -7067,6 +7184,10 @@ async function _checkout({
7067
7184
  .map(async function ([method, fullpath, oid, mode, chmod]) {
7068
7185
  const filepath = `${dir}/${fullpath}`;
7069
7186
  if (method !== 'create-index' && method !== 'mkdir-index') {
7187
+ // Don't write through a symlinked leading path: a parent component that
7188
+ // is a symlink could otherwise redirect the write outside the working
7189
+ // tree. git applies the same check (it does not follow symlinks here).
7190
+ await assertNoSymlinkInLeadingPath(fs, dir, fullpath);
7070
7191
  const { object } = await _readObject({
7071
7192
  fs,
7072
7193
  cache,
@@ -7473,6 +7594,7 @@ async function updateWorkingDir(
7473
7594
  ) {
7474
7595
  const filepath = `${dir}/${fullpath}`;
7475
7596
  if (method !== 'create-index' && method !== 'mkdir-index') {
7597
+ await assertNoSymlinkInLeadingPath(fs, dir, fullpath);
7476
7598
  const { object } = await _readObject({ fs, cache, gitdir, oid });
7477
7599
  if (chmod) {
7478
7600
  await fs.rm(filepath);
@@ -9347,8 +9469,8 @@ function filterCapabilities(server, client) {
9347
9469
 
9348
9470
  const pkg = {
9349
9471
  name: 'isomorphic-git',
9350
- version: '1.38.4',
9351
- agent: 'git/isomorphic-git@1.38.4',
9472
+ version: '1.38.6',
9473
+ agent: 'git/isomorphic-git@1.38.6',
9352
9474
  };
9353
9475
 
9354
9476
  class FIFO {