| … | … | @@ -287,7 +287,9 @@ function linkOrCopy (src, dst) { |
| 287 | 287 | try { |
| 288 | 288 | fs.linkSync(src, dst) |
| 289 | 289 | } catch (err) { |
| 290 | if (err.code === 'EXDEV' || err.code === 'EPERM' || err.code === 'ENOSYS' || err.code === 'ENOTSUP' || err.code === 'EMLINK') { | |
| 290 | if (err.code === 'EXDEV' || err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'ENOSYS' || err.code === 'ENOTSUP' || err.code === 'EMLINK') { | |
| 291 | // EACCES — SELinux `link` denial on no-root Android nodes (celltron): | |
| 292 | // sepolicy forbids link(2) in every unprivileged domain. Copy instead. | |
| 291 | 293 | fs.copyFileSync(src, dst) |
| 292 | 294 | } else { |
| 293 | 295 | throw err |
| … | … | @@ -279,9 +279,16 @@ export class TreeStore { |
| 279 | 279 | } catch (err) { |
| 280 | 280 | if (err.code === 'EEXIST') { |
| 281 | 281 | // Target already exists (concurrent materialize finished). |
| 282 | } else if (err.code === 'EXDEV') { | |
| 282 | } else if (err.code === 'EXDEV' || err.code === 'EACCES' || err.code === 'EPERM') { | |
| 283 | // Hardlink impossible: EXDEV (cross-device), or the OS/policy | |
| 284 | // denies link(2) outright — EPERM (sandboxed fs) or EACCES | |
| 285 | // (SELinux `link` denial, e.g. a no-root Android node / celltron, | |
| 286 | // where sepolicy forbids link in every unprivileged domain). The | |
| 287 | // blob is content-addressed + verified, so copy is correctness- | |
| 288 | // preserving; we only lose inode dedup. Matches the documented | |
| 289 | // "degrades to copy on EXDEV/EPERM/etc." contract in preflight.js. | |
| 283 | 290 | const content = await sem.run(() => opts.fetchContent(e.childHash)) |
| 284 | verifyContent(e.childHash, content, 'file "' + e.name + '" (xdev fallback)') | |
| 291 | verifyContent(e.childHash, content, 'file "' + e.name + '" (link-denied fallback)') | |
| 285 | 292 | fs.writeFileSync(target, content) |
| 286 | 293 | // Read-only: see immutable-blob rationale above. |
| 287 | 294 | fs.chmodSync(target, e.exec ? 0o555 : 0o444) |
| … | … | @@ -1,7 +1,7 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "pgh", |
| 3 | "version": "0.1.117", | |
| 4 | "description": "pgh \u2014 P2P content-addressed package manager and app launcher", | |
| 3 | "version": "0.1.118", | |
| 4 | "description": "pgh — P2P content-addressed package manager and app launcher", | |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| 7 | 7 | "bin": { |
| … | … | @@ -120,6 +120,33 @@ test('materialize — nested subtree', async (t) => { |
| 120 | 120 | fs.rmSync(root, { recursive: true, force: true }) |
| 121 | 121 | }) |
| 122 | 122 | |
| 123 | test('materialize — copy-fallback when hardlink is denied (EACCES / SELinux)', async (t) => { | |
| 124 | const root = mktemp() | |
| 125 | const store = new TreeStore({ root }) | |
| 126 | ||
| 127 | const content = Buffer.from('no-hardlink-here') | |
| 128 | const contentHash = hashBytes(content) | |
| 129 | const entries = [{ kind: KIND_FILE, exec: 0, name: 'f.txt', childHash: contentHash, size: content.length }] | |
| 130 | const treeHash = computeTreeHash(entries) | |
| 131 | ||
| 132 | // Simulate a policy that forbids link(2) — e.g. SELinux on a no-root | |
| 133 | // Android node (celltron), where every linkSync throws EACCES. Before the | |
| 134 | // fix, only EXDEV fell back to copy, so EACCES aborted the whole install. | |
| 135 | const realLink = fs.linkSync | |
| 136 | let linkAttempts = 0 | |
| 137 | fs.linkSync = () => { linkAttempts++; const e = new Error('EACCES: operation not permitted, link'); e.code = 'EACCES'; throw e } | |
| 138 | t.teardown(() => { fs.linkSync = realLink }) | |
| 139 | ||
| 140 | await store.materialize(treeHash, entries, mockFetcher({ [contentHash]: content })) | |
| 141 | ||
| 142 | t.ok(linkAttempts > 0, 'linkSync was attempted and denied') | |
| 143 | t.ok(store.hasTree(treeHash), 'tree still materialized via copy-fallback') | |
| 144 | const materialized = path.join(store.treePath(treeHash), 'f.txt') | |
| 145 | t.is(fs.readFileSync(materialized, 'utf8'), 'no-hardlink-here', 'content correct after copy fallback') | |
| 146 | ||
| 147 | fs.rmSync(root, { recursive: true, force: true }) | |
| 148 | }) | |
| 149 | ||
| 123 | 150 | test('materialize — fetches run concurrently, bounded, and tree round-trips', async (t) => { |
| 124 | 151 | const root = mktemp() |
| 125 | 152 | const store = new TreeStore({ root }) |