PLWL/src/symlinks.js

154 lines
5.1 KiB
JavaScript

'use strict'
// Symlink and permission healing, shared by wine builds, prefixes, game
// imports and LAN sync. Rewires links to relative paths (absolute always;
// relative only when broken) and restores exec bits on build binaries.
const fsp = require('fs').promises
const fs = require('fs')
const path = require('path')
const { pathExists: exists } = require('./fsutil')
// dosdevices/ mappings are supposed to point outside the prefix; never touched.
const SKIP_DIRS = new Set(['dosdevices'])
async function collectSymlinks(dir, out) {
let entries
try {
entries = await fsp.readdir(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
const full = path.join(dir, entry.name)
if (entry.isSymbolicLink()) out.push(full)
else if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) await collectSymlinks(full, out)
}
}
// A missing target can only be something PLWL creates later (fake-steam,
// files/wines/...); the restriction keeps /usr/lib/... links alone.
const ANCHOR_DIRS = new Set(['wines', 'wineprefixes', 'apps', 'data', 'translations'])
// True when `candidate` sits under `root` and its deepest existing parent is
// at least two components inside it.
function anchoredInside(root, candidate) {
let p = candidate
while (p.length > root.length + 1) {
p = path.dirname(p)
if (p === root || !p.startsWith(root + path.sep)) return false
if (exists(p)) return path.relative(root, p).split(path.sep).length >= 2
}
return false
}
// The new target for an absolute link, relative to the link's own directory;
// falls back to the longest suffix that exists locally.
function relativeTarget(link, target, roots) {
const dir = path.dirname(link)
for (const root of roots) {
if (!root) continue
if (target.startsWith(root + path.sep)) {
const rel = path.relative(dir, target)
if (rel && !path.isAbsolute(rel)) return rel
}
}
// second chance for links whose file is not here yet
for (const requireLeaf of [true, false]) {
for (const root of roots) {
if (!root) continue
let i = target.indexOf(path.sep, 1)
while (i !== -1) {
const rest = target.slice(i + 1)
if (rest && !rest.split(path.sep).includes('..')) {
const candidate = path.join(root, rest)
if (candidate.startsWith(root + path.sep)) {
const ok = requireLeaf
? exists(candidate)
: ANCHOR_DIRS.has(rest.replace(/\\/g, '/').split('/')[0]) && anchoredInside(root, candidate)
if (ok) {
const rel = path.relative(dir, candidate)
if (rel && !path.isAbsolute(rel)) return rel
}
}
}
i = target.indexOf(path.sep, i + 1)
}
}
}
return null
}
// Rewire rewritable symlinks under `root`; returns how many changed. Repeats
// until nothing changes (links can chain).
async function rewireSymlinks(root, roots = []) {
if (!root) return 0
const candidates = [root, ...roots.filter(r => r && r !== root)]
const links = []
await collectSymlinks(root, links)
let fixed = 0
for (let pass = 0; pass < 5; pass++) {
let changed = 0
for (const link of links) {
let target
try {
target = await fsp.readlink(link)
} catch {
continue
}
const absolute = path.isAbsolute(target)
// a relative link that still resolves is already relocatable
if (!absolute && exists(path.resolve(path.dirname(link), target))) continue
const from = absolute ? path.normalize(target) : path.resolve(path.dirname(link), target)
const next = relativeTarget(link, from, candidates)
if (!next || next === target) continue
try {
await fsp.unlink(link)
await fsp.symlink(next, link)
fixed++
changed++
} catch {}
}
if (!changed) break
}
return fixed
}
// Binaries a wine/proton build must be able to execute; content copies drop
// the bit and spawn fails with EACCES. Windows .exe files need no bit.
const EXEC_NAMES = new Set(['proton', 'wine', 'wine64', 'wine-preloader', 'wine64-preloader', 'wineserver', 'winetricks', 'wineboot'])
// Give the exec bit back to a build's binaries and bin/ dirs.
async function restoreExecutableBits(root) {
if (!root) return 0
let fixed = 0
const walk = async dir => {
let entries
try {
entries = await fsp.readdir(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
const full = path.join(dir, entry.name)
if (entry.isSymbolicLink()) continue
if (entry.isDirectory()) {
await walk(full)
continue
}
if (!entry.isFile()) continue
const inBin = path.basename(dir) === 'bin'
if (!inBin && !EXEC_NAMES.has(entry.name) && !entry.name.endsWith('.sh')) continue
try {
const st = await fsp.stat(full)
if (st.mode & 0o111) continue
await fsp.chmod(full, (st.mode & 0o777) | 0o755)
fixed++
} catch {}
}
}
await walk(root)
return fixed
}
module.exports = { rewireSymlinks, restoreExecutableBits, collectSymlinks, relativeTarget, EXEC_NAMES }