OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer commits / f746b20

0.3.124: upload-blob over the UDS takes a PATH as well as hex bytes
favela-bridge (lib/bsky.js uploadBlob) hands hpp a picture by path — both ends share one machine, and hex-encoding doubles the transfer and needs a buffer library the client may not have. Until now every such upload failed with "upload-blob: bytes (hex) required", so the bridge posted without the image. Bytes stay supported for remote clients. The socket is 0775 fleeky:fleeky with no other group members, so reading a path grants no access the caller lacks. test/upload-blob-path-uds.test.mjs: red without the handler change on exactly the production error, green with it. Full suite 156/156, 753/753. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
f746b208169097553924c680b6323fff425e1540
by plankatron <fleeky@gmail.com> · 2026-09-29T23:54:58.000Z

Changed files (4) +91 -4

lib/serve-device-uds.js +19 -3
……@@ -7,6 +7,7 @@
77// pgv3 / atproto-bridge use for their local hyper-svc sockets. (The swarm surface keeps canWrite because
88// remote peers are untrusted.) Same op set as serveDevice; a client calls `c.call('<op>', params)`.
99import b4a from 'b4a'
10import fs from 'bare-fs'
1011import { Service } from 'hyper-svc'
1112import { conflictDiag } from './conflict-watch.js'
1213
……@@ -161,9 +162,24 @@ export async function serveDeviceUds (device, { uds, blobs = null, onRelease = n
161162 if (!blob) return null
162163 return { cid: String(cid), mimeType: blob.mimeType, size: blob.size, bytes: b4a.toString(blob.bytes, 'hex') }
163164 })
164 svc.handle('upload-blob', async (ctx, { bytes, mimeType } = {}) => {
165 if (!bytes) throw new Error('upload-blob: bytes (hex) required')
166 const { cid, size } = await blobs.put(b4a.from(bytes, 'hex'), mimeType || 'application/octet-stream')
165 // ⛔ A PATH IS ACCEPTED AS WELL AS BYTES, and for a co-located client it is the better one. This
166 // is a unix socket: both ends are on one machine, and hex-encoding a picture to hand it to a
167 // process that will write it straight back to disk doubles it in transit for nothing. favela-peer
168 // settled the same question the same way ("PATHS cross this socket, never bytes"), and a client
169 // that has to hex-encode needs a buffer library it may not otherwise depend on — which is a
170 // MODULE_NOT_FOUND at boot waiting to happen, not a detail.
171 //
172 // Bytes stay supported: a REMOTE client has no shared filesystem and no path to offer.
173 svc.handle('upload-blob', async (ctx, { bytes, path, mimeType } = {}) => {
174 if (!bytes && !path) throw new Error('upload-blob: bytes (hex) or path required')
175 let buf
176 if (path) {
177 try { buf = fs.readFileSync(path) } catch { throw new Error('upload-blob: no file at ' + path) }
178 } else {
179 buf = b4a.from(bytes, 'hex')
180 }
181 if (!buf || !buf.length) throw new Error('upload-blob: that file is empty')
182 const { cid, size } = await blobs.put(buf, mimeType || 'application/octet-stream')
167183 return { cid: cid.toString(), mimeType: mimeType || 'application/octet-stream', size }
168184 })
169185 }
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "hyper-proto-peer",
3 "version": "0.3.123",
3 "version": "0.3.124",
44 "description": "Self-custody, P2P-native atproto peer \u2014 holds the user's key + content as Hypercores and authors+signs its own repo. The source of truth in the peer/bridge split (Model B); atproto-bridge projects it to bsky.",
55 "type": "module",
66 "main": "lib/peer.js",
test/all.js +1 -0
……@@ -10,6 +10,7 @@ import './side-store-channel-priority.test.js' // fast + offline
1010import './shared-store-autoopen.test.js' // fast + offline; corestore auto-opens on-disk cores on remote interest (#22)
1111import './inline-migration-boot.test.mjs' // fast + offline
1212import './blob-store-exposed.test.mjs' // fast + offline; the returned handle must report the store it has
13import './upload-blob-path-uds.test.mjs' // fast + offline; favela-bridge uploads BY PATH over the UDS
1314import './mint.test.mjs' // fast + offline; creating an identity on the device that will use it
1415import './hosting-cli.test.mjs' // fast + offline; the invite ceremony's terminal half
1516import './swarm-device.test.js'
test/upload-blob-path-uds.test.mjs +70 -0
……@@ -0,0 +1,70 @@
1// upload-blob over the UDS accepts a PATH as well as hex bytes.
2//
3// favela-bridge (lib/bsky.js uploadBlob) hands hpp a picture BY PATH — both ends share one machine, and
4// hex-encoding it doubles the transfer and needs a buffer library the client may not have. Until this
5// handler took `path`, every such upload failed with "bytes (hex) required" and the bridge logged
6// blob-upload-failed and posted without the image.
7//
8// The socket is 0775 fleeky:fleeky: only the daemon's own user can connect, so reading a path grants no
9// access that caller does not already have.
10//
11// Run: bare test/upload-blob-path-uds.test.mjs
12import { serveDeviceUds } from '../lib/serve-device-uds.js'
13import { Client } from 'hyper-svc'
14import b4a from 'b4a'
15import fs from 'bare-fs'
16import process from 'bare-process'
17import { join } from 'bare-path'
18
19const assert = (cond, msg) => { if (!cond) { console.error('FAIL:', msg); process.exit(1) } console.log(' ok -', msg) }
20const tmp = join(process.env.TMPDIR || '/tmp', 'hpp-upload-path-' + process.pid)
21fs.mkdirSync(tmp, { recursive: true })
22
23// The handler is only registered when a blob store is passed; a stub records exactly what it was given.
24const stored = []
25const blobs = {
26 async put (buf, mimeType) { stored.push({ buf, mimeType }); return { cid: 'bafystub' + stored.length, size: buf.length } }
27}
28const device = { state: () => ({ ok: true }), membersDiag: async () => ({}), did: 'did:plc:test' }
29
30const sock = join(tmp, 'u.sock')
31const served = await serveDeviceUds(device, { uds: sock, blobs })
32const c = await Client.connectUds(sock)
33const rejects = async (params) => { try { await c.call('upload-blob', params); return null } catch (e) { return e.message } }
34
35console.log('# a path uploads the file\'s exact bytes')
36{
37 const file = join(tmp, 'pic.png')
38 const bytes = b4a.from('not really a png, but exact bytes are the point')
39 fs.writeFileSync(file, bytes)
40 const r = await c.call('upload-blob', { path: file, mimeType: 'image/png' })
41 assert(r && r.cid === 'bafystub1', 'upload-blob by path returns the store\'s CID')
42 assert(r.size === bytes.length && r.mimeType === 'image/png', 'size and mimeType are what was stored')
43 assert(b4a.equals(stored[0].buf, bytes), 'the store received the file\'s bytes, unchanged')
44}
45
46console.log('\n# hex bytes still work — a remote client has no path to offer')
47{
48 const bytes = b4a.from('bytes over the wire')
49 const r = await c.call('upload-blob', { bytes: b4a.toString(bytes, 'hex'), mimeType: 'image/jpeg' })
50 assert(r && r.size === bytes.length, 'upload-blob by bytes still stores them')
51 assert(b4a.equals(stored[1].buf, bytes), 'and they decode exactly')
52}
53
54console.log('\n# failures say what is wrong, and store nothing')
55{
56 const before = stored.length
57 const missing = await rejects({ path: join(tmp, 'nope.png') })
58 assert(missing && /no file at/.test(missing), `a missing path is named (${missing})`)
59 const empty = join(tmp, 'empty.png'); fs.writeFileSync(empty, b4a.alloc(0))
60 const e2 = await rejects({ path: empty })
61 assert(e2 && /empty/.test(e2), `an empty file is refused (${e2})`)
62 const e3 = await rejects({})
63 assert(e3 && /bytes \(hex\) or path required/.test(e3), `neither bytes nor path is refused (${e3})`)
64 assert(stored.length === before, 'no failed call reached the store')
65}
66
67try { await c.close() } catch {}
68try { await served.close() } catch {}
69fs.rmSync(tmp, { recursive: true, force: true })
70console.log('\nupload-blob path: all assertions passed')