improve native isolation
This commit is contained in:
parent
3f57682a9b
commit
9748b98163
15 changed files with 341 additions and 95 deletions
|
|
@ -13,7 +13,8 @@ const { prefixPath, prefixLinuxRoot } = require('./prefixes')
|
|||
const { prefixNameForGame, loadSettings } = require('./library')
|
||||
const i18n = require('./i18n')
|
||||
const { spawnTracked, parseArgs } = require('./runner')
|
||||
const { buildBwrapArgs, SHARE_ROOT } = require('./sandbox')
|
||||
const { buildBwrapArgs, SHARE_ROOT, FS_ROOT } = require('./sandbox')
|
||||
const { copyFileProgress } = require('./fsutil')
|
||||
|
||||
const DATA_DIRS = ['home', 'config', 'data', 'cache', 'state']
|
||||
|
||||
|
|
@ -424,21 +425,58 @@ function buildLinuxEnv({ game, backend = 'env' }) {
|
|||
return { env, root, home, prefixName, prefixDir: prefixPath(prefixName), routing: urlRouting({ root, home, sandboxed }) }
|
||||
}
|
||||
|
||||
// bwrap when it works, plain run otherwise - unless the app demands a sandbox.
|
||||
function pickBackend({ game, sandbox }) {
|
||||
const policy = (game && game.isolation) || 'auto'
|
||||
const status = sandbox && typeof sandbox.status === 'function' ? sandbox.status() : null
|
||||
const available = !!status && status.backend === 'bwrap'
|
||||
if (policy === 'off') return { ok: true, backend: 'env', policy, status }
|
||||
if (available) return { ok: true, backend: 'bwrap', policy, status }
|
||||
if (policy === 'require') return { ok: false, error: 'sandbox-required', policy, status }
|
||||
return { ok: true, backend: 'env', policy, status }
|
||||
// Where a handed document goes inside the app's own home. The prefix home is
|
||||
// a live bind of a host folder, so a copy written here is visible to an app
|
||||
// that is already running - the one route a running namespace can be reached
|
||||
// through at all.
|
||||
const HANDOFF_DIR = 'plwl-handoff'
|
||||
// Copies above this are worth a popup: below it the copy is over before a
|
||||
// window could paint, and the launcher would flash for nothing.
|
||||
const STAGE_NOTICE_BYTES = 50 * 1000 * 1000
|
||||
|
||||
// The probe measures once and answers from its cache afterwards. It is awaited
|
||||
// here because a shortcut boots HEADLESS: no renderer ran, so nothing else
|
||||
// would have measured, and "auto" would quietly pick the weaker level.
|
||||
async function levelStatus(sandbox) {
|
||||
if (!sandbox || typeof sandbox.status !== 'function') return null
|
||||
const cached = sandbox.status()
|
||||
if (cached) return cached
|
||||
if (typeof sandbox.probe !== 'function') return null
|
||||
try {
|
||||
return await sandbox.probe()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isolationLine({ backend, status }) {
|
||||
if (backend === 'bwrap') return `[plwl] isolation: bwrap ${(status && status.version) || ''}`.trim()
|
||||
const reason = (status && status.reason) || 'missing'
|
||||
return `[plwl] isolation: env-only (reason: ${reason})`
|
||||
// bwrap when it works, plain run otherwise - unless the app demands a sandbox.
|
||||
// `auto` is the only level allowed to take a weaker one: a machine that cannot
|
||||
// sandbox runs the app plain, while an explicit relaxed/isolated refuses
|
||||
// instead of quietly dropping the promise. (Values written by launcher 0.5.x
|
||||
// are mapped to these four by library.js.)
|
||||
async function resolveLevel({ game, sandbox }) {
|
||||
const policy = (game && game.isolation) || 'auto'
|
||||
const status = await levelStatus(sandbox)
|
||||
const available = !!status && status.backend === 'bwrap'
|
||||
if (policy === 'none') return { ok: true, level: 'none', backend: 'env', status }
|
||||
if (policy === 'auto') {
|
||||
return { ok: true, level: available ? 'relaxed' : 'none', backend: available ? 'bwrap' : 'env', status }
|
||||
}
|
||||
if (!available) return { ok: false, error: 'sandbox-required', level: policy, backend: 'env', status }
|
||||
return { ok: true, level: policy, backend: 'bwrap', status }
|
||||
}
|
||||
|
||||
// The line the app log and the UI both quote: a silent downgrade is the one
|
||||
// failure this whole setting exists to avoid.
|
||||
function isolationLine({ level, status }) {
|
||||
const version = (status && status.version) || ''
|
||||
if (level === 'none') {
|
||||
const reason = (status && status.reason) || ''
|
||||
return reason ? `[plwl] isolation: none (no sandbox: ${reason})` : '[plwl] isolation: none (plain run)'
|
||||
}
|
||||
const bwrap = version ? `bwrap ${version}` : 'bwrap'
|
||||
if (level === 'relaxed') return `[plwl] isolation: relaxed (${bwrap}, host filesystem at ${FS_ROOT})`
|
||||
return `[plwl] isolation: isolated (${bwrap})`
|
||||
}
|
||||
|
||||
// "Open with": a native app takes the file where it is (the sandbox sees /).
|
||||
|
|
@ -450,24 +488,39 @@ function linuxPathFor(file) {
|
|||
return abs
|
||||
}
|
||||
|
||||
// The document a shortcut was handed. The sandbox cannot hand it over by
|
||||
// itself: a file out of the user's home is buried under the prefix home, one
|
||||
// out of /tmp sits behind the private tmpfs, and the rest of the machine is
|
||||
// read-only. So it is mounted where the app was told it is, writable - the way
|
||||
// a Wine app gets it as Z:\... and may save it. Prefix data needs nothing: it
|
||||
// already is the app's own writable home.
|
||||
function handedFileBinds({ openFile, root }) {
|
||||
if (!openFile) return []
|
||||
let abs = ''
|
||||
try {
|
||||
abs = fs.realpathSync(String(openFile))
|
||||
if (!fs.statSync(abs).isFile()) return []
|
||||
} catch {
|
||||
// a path that is gone must not become a bind bwrap would refuse
|
||||
return []
|
||||
// The document a shortcut was handed, in the one shape the chosen level can
|
||||
// reach - and the app is told nothing else, so a second hand-off names the
|
||||
// same file the same way.
|
||||
// none the host path, exactly what a shell would name
|
||||
// relaxed the same file through the whole-host mount every run carries
|
||||
// isolated a copy in the app's own home, refreshed on every open (one copy
|
||||
// per name, so the folder cannot grow; the app's edits stay in the
|
||||
// copy - that is this level's promise)
|
||||
// Returns '' when there is nothing to hand over, so argv stays honest.
|
||||
async function handedPathFor({ openFile, level, root, home, onProgress }) {
|
||||
if (!openFile) return ''
|
||||
const abs = linuxPathFor(openFile)
|
||||
if (level === 'relaxed') return path.join(FS_ROOT, abs)
|
||||
if (level !== 'isolated') return abs
|
||||
|
||||
const name = path.basename(abs)
|
||||
if (!name || name === '.' || name === '..') return ''
|
||||
const stat = fs.statSync(abs)
|
||||
if (!stat.isFile()) return ''
|
||||
const total = stat.size
|
||||
const notice = total >= STAGE_NOTICE_BYTES
|
||||
const emit = (phase, bytes, error) => {
|
||||
if (onProgress) onProgress({ phase, name, bytes: bytes || 0, totalBytes: total, notice, error: error || '' })
|
||||
}
|
||||
if (abs === root || abs.startsWith(root + path.sep)) return []
|
||||
return [abs]
|
||||
emit('copy', 0)
|
||||
try {
|
||||
await copyFileProgress(abs, path.join(root, 'home', HANDOFF_DIR, name), p => emit('copy', p.bytes))
|
||||
} catch (err) {
|
||||
emit('error', 0, String((err && err.message) || err))
|
||||
throw err
|
||||
}
|
||||
emit('done', total)
|
||||
return path.join(home, HANDOFF_DIR, name)
|
||||
}
|
||||
|
||||
function bwrapSource() {
|
||||
|
|
@ -483,16 +536,17 @@ function bwrapSource() {
|
|||
}
|
||||
}
|
||||
|
||||
async function runLinuxGame({ game, sandbox, onExit, openFile, handoff = false }) {
|
||||
const chosen = pickBackend({ game, sandbox })
|
||||
async function runLinuxGame({ game, sandbox, onExit, openFile, handoff = false, onProgress }) {
|
||||
const chosen = await resolveLevel({ game, sandbox })
|
||||
if (!chosen.ok) throw new Error(chosen.error)
|
||||
|
||||
const exe = game.exePath
|
||||
const workdir = game.workdir || path.dirname(exe)
|
||||
const { env, root, home, routing } = buildLinuxEnv({ game, backend: chosen.backend })
|
||||
const argList = parseArgs(game.args)
|
||||
if (openFile) argList.push(linuxPathFor(openFile))
|
||||
const handed = await handedPathFor({ openFile, level: chosen.level, root, home, onProgress })
|
||||
if (handed) argList.push(handed)
|
||||
|
||||
const { env, root, routing } = buildLinuxEnv({ game, backend: chosen.backend })
|
||||
const header = [isolationLine(chosen)]
|
||||
if (game.forceX11) header.push('[plwl] x11 forced (XWayland)')
|
||||
|
||||
|
|
@ -501,7 +555,7 @@ async function runLinuxGame({ game, sandbox, onExit, openFile, handoff = false }
|
|||
if (chosen.backend === 'bwrap') {
|
||||
const source = bwrapSource()
|
||||
cmd = 'bwrap'
|
||||
args = [...buildBwrapArgs({ dataRoot: root, user: source.user, devices: source.devices, x11: source.x11, runUser: source.runUser, shares: source.shares, appDir: writableAppDir(game), openFiles: handedFileBinds({ openFile, root }), urlOpener: routing }), '--', exe, ...argList]
|
||||
args = [...buildBwrapArgs({ dataRoot: root, user: source.user, devices: source.devices, x11: source.x11, runUser: source.runUser, shares: source.shares, appDir: writableAppDir(game), fsRoot: chosen.level === 'relaxed', urlOpener: routing }), '--', exe, ...argList]
|
||||
header.push(`[plwl] HOME=${env.HOME}`)
|
||||
}
|
||||
|
||||
|
|
@ -510,6 +564,8 @@ async function runLinuxGame({ game, sandbox, onExit, openFile, handoff = false }
|
|||
|
||||
module.exports = {
|
||||
DESKTOP_SEEDS,
|
||||
HANDOFF_DIR,
|
||||
STAGE_NOTICE_BYTES,
|
||||
SHIM_REL,
|
||||
OPENER_REL,
|
||||
MIME_REL,
|
||||
|
|
@ -525,9 +581,9 @@ module.exports = {
|
|||
resetSeededConfig,
|
||||
dataShares,
|
||||
buildLinuxEnv,
|
||||
pickBackend,
|
||||
resolveLevel,
|
||||
isolationLine,
|
||||
linuxPathFor,
|
||||
handedFileBinds,
|
||||
handedPathFor,
|
||||
runLinuxGame
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue