can1357/oh-my-pi · error · ArchiveError

Archive path ' ' 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.

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.

Appendix: 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)