| … | … | @@ -42,6 +42,44 @@ function execGit (repoPath, args, timeout) { |
| 42 | 42 | }) |
| 43 | 43 | } |
| 44 | 44 | |
| 45 | /** | |
| 46 | * Like execGit but resolves the raw stdout Buffer instead of a UTF-8 string. | |
| 47 | * Required for binary blobs (images, etc.) — toString() corrupts them. | |
| 48 | */ | |
| 49 | function execGitBuffer (repoPath, args, timeout) { | |
| 50 | return new Promise((resolve, reject) => { | |
| 51 | const proc = spawn('git', ['-C', repoPath, ...args], { | |
| 52 | stdio: ['ignore', 'pipe', 'pipe'] | |
| 53 | }) | |
| 54 | ||
| 55 | const chunks = [] | |
| 56 | const errorChunks = [] | |
| 57 | ||
| 58 | const timer = setTimeout(() => { | |
| 59 | proc.kill('SIGTERM') | |
| 60 | reject(new Error('git command timed out: git ' + args.join(' '))) | |
| 61 | }, timeout || 30000) | |
| 62 | ||
| 63 | proc.stdout.on('data', (chunk) => chunks.push(chunk)) | |
| 64 | proc.stderr.on('data', (chunk) => errorChunks.push(chunk)) | |
| 65 | ||
| 66 | proc.on('close', (code) => { | |
| 67 | clearTimeout(timer) | |
| 68 | if (code !== 0) { | |
| 69 | const errMsg = Buffer.concat(errorChunks).toString().trim() | |
| 70 | reject(new Error(errMsg || 'git exited with code ' + code)) | |
| 71 | } else { | |
| 72 | resolve(Buffer.concat(chunks)) | |
| 73 | } | |
| 74 | }) | |
| 75 | ||
| 76 | proc.on('error', (err) => { | |
| 77 | clearTimeout(timer) | |
| 78 | reject(err) | |
| 79 | }) | |
| 80 | }) | |
| 81 | } | |
| 82 | ||
| 45 | 83 | /** |
| 46 | 84 | * Browser methods to mix into HyperSync.prototype |
| 47 | 85 | */ |
| … | … | @@ -81,6 +119,14 @@ const browserMethods = { |
| 81 | 119 | return execGit(repoPath, ['show', ref + ':' + filePath]) |
| 82 | 120 | }, |
| 83 | 121 | |
| 122 | // Raw blob bytes (Buffer) — for binary files like images that must not be | |
| 123 | // coerced through toString(). Mirrors readFile but preserves exact bytes. | |
| 124 | async readFileRaw (name, filePath, ref) { | |
| 125 | ref = ref || 'HEAD' | |
| 126 | const repoPath = this._repoPath(name) | |
| 127 | return execGitBuffer(repoPath, ['show', ref + ':' + filePath]) | |
| 128 | }, | |
| 129 | ||
| 84 | 130 | async getCommits (name, ref, limit) { |
| 85 | 131 | ref = ref || 'HEAD' |
| 86 | 132 | limit = limit || 20 |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "hyper-sync", |
| 3 | "version": "0.1.0", | |
| 3 | "version": "0.1.1", | |
| 4 | 4 | "description": "Manage and sync bare git repos over Hyperswarm", |
| 5 | 5 | "main": "index.js", |
| 6 | 6 | "imports": { |
| … | … | @@ -168,3 +168,40 @@ test('HyperSync rescan detects changes', async (t) => { |
| 168 | 168 | await sync.close(); |
| 169 | 169 | fs.rmSync(tmpRepos, { recursive: true }); |
| 170 | 170 | }); |
| 171 | ||
| 172 | test('readFileRaw returns exact binary bytes (readFile would corrupt)', async (t) => { | |
| 173 | // Commit a blob with non-UTF-8 bytes (0x00, 0x80, 0xFF) — a stand-in for an | |
| 174 | // image. readFile() decodes to a string and mangles these; readFileRaw() | |
| 175 | // must round-trip them byte-for-byte. | |
| 176 | const { execSync } = require('child_process'); | |
| 177 | const reposRoot = fs.mkdtempSync(path.join(tmpdir, 'hyper-sync-raw-')); | |
| 178 | const bareDir = path.join(reposRoot, 'raw-test.git'); | |
| 179 | execSync(`git init --bare ${bareDir}`, { stdio: 'ignore' }); | |
| 180 | ||
| 181 | const work = fs.mkdtempSync(path.join(tmpdir, 'hyper-sync-raw-work-')); | |
| 182 | const g = (a) => execSync(`git -C ${work} ${a}`, { stdio: 'ignore' }); | |
| 183 | g('init --initial-branch=main'); | |
| 184 | g('config user.email t@t'); | |
| 185 | g('config user.name T'); | |
| 186 | ||
| 187 | const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x80, 0xff, 0x0a, 0x1f, 0x2b]); | |
| 188 | fs.writeFileSync(path.join(work, 'pixel.png'), bytes); | |
| 189 | g('add -A'); | |
| 190 | g('commit -m img'); | |
| 191 | g(`push ${bareDir} main`); | |
| 192 | ||
| 193 | const sync = new HyperSync({ repos: reposRoot }); | |
| 194 | await sync.ready(); | |
| 195 | ||
| 196 | const raw = await sync.readFileRaw('raw-test', 'pixel.png', 'main'); | |
| 197 | t.ok(Buffer.isBuffer(raw), 'readFileRaw returns a Buffer'); | |
| 198 | t.alike(raw, bytes, 'bytes round-trip exactly'); | |
| 199 | ||
| 200 | // Demonstrate why raw is needed: the string path loses the original bytes. | |
| 201 | const asStr = await sync.readFile('raw-test', 'pixel.png', 'main'); | |
| 202 | t.not(Buffer.from(asStr).length, bytes.length, 'readFile string path corrupts byte length'); | |
| 203 | ||
| 204 | await sync.close(); | |
| 205 | fs.rmSync(reposRoot, { recursive: true }); | |
| 206 | fs.rmSync(work, { recursive: true }); | |
| 207 | }); |