can1357/oh-my-pi · error · ArchiveError

Archive path '${archivePath}' crosses a cyclic symlink

Error message

Archive path '${archivePath}' crosses a cyclic symlink

What it means

When resolving archive entry paths, the library rewrites symlink aliases up to maxLinkDepth times (default 40). If a path still needs another rewrite after that many steps, the link chain is considered cyclic (an a->b->a loop or an over-long chain) and the library refuses to resolve it rather than looping forever. This protects callers from infinite aliasing inside untrusted archives.

Source

Thrown at packages/utils/src/ar/entries.ts:96

		let replacement: string | undefined;
		for (let end = resolvedPath.length; end > 0; end = resolvedPath.lastIndexOf("/", end - 1)) {
			const entry = entries.get(resolvedPath.slice(0, end));
			if (entry?.storage?.type !== "link" || (!entry.isDirectory && !entry.storage.resolveTarget)) continue;
			const suffix = resolvedPath.slice(end + 1);
			replacement = suffix
				? entry.storage.targetPath
					? `${entry.storage.targetPath}/${suffix}`
					: suffix
				: entry.storage.targetPath;
			break;
		}
		if (replacement === undefined) return resolvedPath;
		// The bound counts performed rewrites, so a chain of exactly
		// maxLinkDepth aliases still resolves; only needing one more trips it.
		if (++rewrites > maxLinkDepth) break;
		resolvedPath = replacement;
	}
	throw new ArchiveError(`Archive path '${archivePath}' crosses a cyclic symlink`);
}

View on GitHub (pinned to 9690622007)

Solutions

  1. Inspect the archive's symlink members and break the cycle (delete or rename the looping link) before re-reading
  2. If the chain is legitimate but deep, raise the limits option's maxLinkDepth when calling the reader
  3. Extract with symlink resolution disabled or into a location where you resolve links manually
  4. Treat the archive as untrusted if the cycle is intentional — reject or sanitize it

Example fix

// before (default 40-link bound trips)
const entries = await readArchive(cyclicDeb);
// after
const entries = await readArchive(cyclicDeb, { limits: { ...DEFAULT_ARCHIVE_LIMITS, maxLinkDepth: 80 } });
Defensive patterns

Strategy: validation

Validate before calling

// pre-scan symlinks for cycles before resolving paths
function detectCycle(links) {
  const seen = new Set();
  const visit = (p) => {
    if (seen.has(p)) return true;
    seen.add(p);
    const target = links.get(p);
    return target ? visit(target) : false;
  };
  return [...links.keys()].some(visit);
}

Type guard

function isCyclicSymlinkError(err) {
  return err instanceof ArchiveError && err.message.includes('crosses a cyclic symlink');
}

Try / catch

try {
  resolved = resolvedPath(entry);
} catch (err) {
  if (err instanceof ArchiveError && err.message.includes('cyclic symlink')) {
    return null; // skip unresolvable entry
  }
  throw err;
}

Prevention

When it happens

Trigger: Reading or resolving an entry whose path traverses symlinks that form a cycle — e.g. members 'a' -> 'b', 'b' -> 'a', then resolving path 'a/file.txt' — or a legitimate symlink chain deeper than maxLinkDepth (40) links, via resolvedPath, resolvedChildPath, or resolvePendingLinks during archive indexing/extraction.

Common situations: Malicious archives crafted with symlink loops (zip-symlink/deb data.tar attacks), and rare legitimate archives with extremely deep symlink chains exceeding the 40-link bound.

Related errors


AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31). Data as JSON: /api/errors/1aaa41f322d4885b. Report an issue: GitHub.