OverviewFilesCommitsIssuesDocsReleasesPeers

pgh commits / 527644a

store/materialize: copy-fallback on EACCES/EPERM (no-root SELinux link denial)
The blob hardlink fallback only caught EXDEV, so a filesystem/policy that denies link(2) outright — EACCES on a no-root Android node (celltron), where SELinux forbids link in every unprivileged domain — aborted the whole install mid-materialize. preflight.js already documents the intended contract as "degrades to copy on EXDEV/EPERM/etc."; this makes store.js and materializers/node.js honor it. Copy is correctness-preserving (blobs are blake2b-addressed + verified); only inode dedup is lost. Regression test stubs fs.linkSync to throw EACCES and asserts the tree still materializes via copy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
527644abf2a14e7dc695a291b5e132fc289a9ac1
by plankatron <fleeky@gmail.com> · 2026-07-15T00:51:39.000Z

Changed files (4) +41 -5

lib/materializers/node.js +3 -1
……@@ -287,7 +287,9 @@ function linkOrCopy (src, dst) {
287287 try {
288288 fs.linkSync(src, dst)
289289 } 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.
291293 fs.copyFileSync(src, dst)
292294 } else {
293295 throw err
lib/store.js +9 -2
……@@ -279,9 +279,16 @@ export class TreeStore {
279279 } catch (err) {
280280 if (err.code === 'EEXIST') {
281281 // 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.
283290 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)')
285292 fs.writeFileSync(target, content)
286293 // Read-only: see immutable-blob rationale above.
287294 fs.chmodSync(target, e.exec ? 0o555 : 0o444)
package.json +2 -2
……@@ -1,7 +1,7 @@
11{
22 "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",
55 "type": "module",
66 "main": "index.js",
77 "bin": {
test/pgh-store.test.js +27 -0
……@@ -120,6 +120,33 @@ test('materialize — nested subtree', async (t) => {
120120 fs.rmSync(root, { recursive: true, force: true })
121121})
122122
123test('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
123150test('materialize — fetches run concurrently, bounded, and tree round-trips', async (t) => {
124151 const root = mktemp()
125152 const store = new TreeStore({ root })