| … | … | @@ -106,11 +106,11 @@ export class Avatars { |
| 106 | 106 | if (typeof oid !== 'string' || !/^[0-9a-f]{8,128}$/i.test(oid)) return null |
| 107 | 107 | const p = this._path(oid) |
| 108 | 108 | |
| 109 | const describe = () => { | |
| 109 | const describe = (at = p) => { | |
| 110 | 110 | try { |
| 111 | const bytes = fs.readFileSync(p) | |
| 111 | const bytes = fs.readFileSync(at) | |
| 112 | 112 | const mimeType = sniff(bytes) |
| 113 | return mimeType ? { path: p, mimeType, size: bytes.length } : null | |
| 113 | return mimeType ? { path: at, mimeType, size: bytes.length } : null | |
| 114 | 114 | } catch { |
| 115 | 115 | return null |
| 116 | 116 | } |
| … | … | @@ -123,7 +123,13 @@ export class Avatars { |
| 123 | 123 | fs.mkdirSync(this.dir, { recursive: true }) |
| 124 | 124 | const r = await this.overlay.fetchFile(oid, { destPath: p }) |
| 125 | 125 | if (!r) return null |
| 126 | return describe() | |
| 126 | // ⛔ DESCRIBE WHERE IT ACTUALLY IS. On a LOCAL hit the overlay returns the path it already | |
| 127 | // holds the bytes at — which may be the OTHER store's directory, since Files and Avatars | |
| 128 | // fetch the same content hash from the same overlay and differ only in where they cache. | |
| 129 | // Re-reading our own expected path meant a file the overlay had was reported missing: on | |
| 130 | // ashatron, `file-get` for its own avatar answered {found:false} while the bytes sat in | |
| 131 | // avatars/<oid>, and every consumer downstream faithfully rendered no picture. | |
| 132 | return describe(r.destPath || p) | |
| 127 | 133 | } catch { |
| 128 | 134 | return null |
| 129 | 135 | } |
| … | … | @@ -164,15 +164,15 @@ export class Files { |
| 164 | 164 | if (typeof oid !== 'string' || !/^[0-9a-f]{8,128}$/i.test(oid)) return null |
| 165 | 165 | const p = this._path(oid) |
| 166 | 166 | |
| 167 | const describe = () => { | |
| 167 | const describe = (at = p) => { | |
| 168 | 168 | try { |
| 169 | const fd = fs.openSync(p, 'r') | |
| 169 | const fd = fs.openSync(at, 'r') | |
| 170 | 170 | const head = b4a.alloc(4096) |
| 171 | 171 | let n = 0 |
| 172 | 172 | try { n = fs.readSync(fd, head, 0, head.length, 0) } finally { fs.closeSync(fd) } |
| 173 | 173 | const mimeType = sniff(head.subarray(0, n)) |
| 174 | 174 | if (!mimeType) return null |
| 175 | return { path: p, mimeType, size: fs.statSync(p).size } | |
| 175 | return { path: at, mimeType, size: fs.statSync(at).size } | |
| 176 | 176 | } catch { |
| 177 | 177 | return null |
| 178 | 178 | } |
| … | … | @@ -185,7 +185,13 @@ export class Files { |
| 185 | 185 | fs.mkdirSync(this.dir, { recursive: true }) |
| 186 | 186 | const r = await this.overlay.fetchFile(oid, { destPath: p }) |
| 187 | 187 | if (!r) return null |
| 188 | return describe() | |
| 188 | // ⛔ DESCRIBE WHERE IT ACTUALLY IS. On a LOCAL hit the overlay returns the path it already | |
| 189 | // holds the bytes at — which may be the OTHER store's directory, since Files and Avatars | |
| 190 | // fetch the same content hash from the same overlay and differ only in where they cache. | |
| 191 | // Re-reading our own expected path meant a file the overlay had was reported missing: on | |
| 192 | // ashatron, `file-get` for its own avatar answered {found:false} while the bytes sat in | |
| 193 | // avatars/<oid>, and every consumer downstream faithfully rendered no picture. | |
| 194 | return describe(r.destPath || p) | |
| 189 | 195 | } catch { |
| 190 | 196 | return null |
| 191 | 197 | } |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-peer", |
| 3 | "version": "0.0.45", | |
| 3 | "version": "0.0.46", | |
| 4 | 4 | "description": "The favela daemon — feeds and documents of markdown records over Hypercore. No atproto, no ActivityPub.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "scripts": { |
| … | … | @@ -0,0 +1,53 @@ |
| 1 | // ⛔ THE BUG. Files and Avatars fetch from the SAME overlay by the SAME content hash, and differ | |
| 2 | // only in where they cache locally — blobs/<oid> vs avatars/<oid>. But get() ignores the path | |
| 3 | // fetchFile RETURNS and re-reads its own expected path. So when the overlay already holds the bytes | |
| 4 | // under the other store's directory, the file is found and then looked for somewhere it is not. | |
| 5 | // | |
| 6 | // Measured on ashatron: its own avatar sat at avatars/<oid>, and file-get for that oid answered | |
| 7 | // {found:false} on the machine holding it. Which made the bridge's per-user blob fetcher look | |
| 8 | // broken when it was faithfully reporting what favela-peer told it. | |
| 9 | ||
| 10 | import test from 'brittle' | |
| 11 | import fs from '#node:fs' | |
| 12 | import os from '#node:os' | |
| 13 | import { join } from '#node:path' | |
| 14 | import { Files } from '../lib/files.js' | |
| 15 | ||
| 16 | const PNG = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex') | |
| 17 | const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'xstore-')) | |
| 18 | ||
| 19 | test('★★ a file the overlay holds under ANOTHER path is still found', async (t) => { | |
| 20 | const storage = tmp() | |
| 21 | const oid = 'bb02a15a7f755740174f4ae6b9b4a9724cd53d4159878844db79e3ce9f94b580' | |
| 22 | ||
| 23 | // The bytes exist, cached by the AVATARS store — a different directory from the one Files uses. | |
| 24 | const elsewhere = join(storage, 'avatars') | |
| 25 | fs.mkdirSync(elsewhere, { recursive: true }) | |
| 26 | fs.writeFileSync(join(elsewhere, oid), PNG) | |
| 27 | ||
| 28 | const files = new Files(storage, { | |
| 29 | // What hyper-overlay does on a local hit: returns where it ALREADY is, not where you asked. | |
| 30 | async fetchFile (hash, opts) { return { destPath: join(elsewhere, hash), local: true, size: PNG.length } } | |
| 31 | }) | |
| 32 | ||
| 33 | const got = await files.get(oid) | |
| 34 | t.ok(got, 'found, rather than reported missing on the machine holding it') | |
| 35 | t.is(got.mimeType, 'image/png', 'and typed from the bytes') | |
| 36 | t.is(got.path, join(elsewhere, oid), 'at the path the overlay actually has it') | |
| 37 | }) | |
| 38 | ||
| 39 | test('★ a fetch that lands in this store\'s own directory still works', async (t) => { | |
| 40 | const storage = tmp() | |
| 41 | const oid = 'c'.repeat(64) | |
| 42 | const files = new Files(storage, { | |
| 43 | async fetchFile (hash, opts) { fs.writeFileSync(opts.destPath, PNG); return { destPath: opts.destPath, local: false } } | |
| 44 | }) | |
| 45 | const got = await files.get(oid) | |
| 46 | t.ok(got) | |
| 47 | t.is(got.mimeType, 'image/png') | |
| 48 | }) | |
| 49 | ||
| 50 | test('⛔ genuinely absent is still null', async (t) => { | |
| 51 | const files = new Files(tmp(), { async fetchFile () { return null } }) | |
| 52 | t.is(await files.get('d'.repeat(64)), null) | |
| 53 | }) |