/**
* Pointer Format — git clean/smudge filter interchange format
*
* Stored in git objects instead of actual binary file content.
* Two formats based on chunk count:
*
* Small file (≤64 chunks, inline):
* hyper-overlay v1
* oid blake2b:<file-hash>
* size <bytes>
* chunks <count>
* blake2b:<chunk-0-hash> <chunk-0-size>
* blake2b:<chunk-1-hash> <chunk-1-size>
* ...
*
* Large file (>64 chunks, manifest reference):
* hyper-overlay v1
* oid blake2b:<file-hash>
* size <bytes>
* chunks <count>
* manifest blake2b:<manifest-hash>
*/
const VERSION = 1
const HEADER = 'hyper-overlay v1'
const INLINE_MAX = 64
/**
* Serialize file info to pointer string
*
* @param {Object} info
* @param {string} info.oid - blake2b hex hash of complete file
* @param {number} info.size - total file size in bytes
* @param {number} info.chunkCount - number of chunks
* @param {Array<{hash: string, size: number}>} [info.chunks] - inline chunk list (≤64)
* @param {string} [info.manifestHash] - manifest hash for large files (>64 chunks)
* @returns {string}
*/
export function serialize (info) {
const lines = [
HEADER,
`oid blake2b:${info.oid}`,
`size ${info.size}`,
`chunks ${info.chunkCount}`
]
if (info.chunks && info.chunkCount <= INLINE_MAX) {
for (const c of info.chunks) {
lines.push(`blake2b:${c.hash} ${c.size}`)
}
} else if (info.manifestHash) {
lines.push(`manifest blake2b:${info.manifestHash}`)
}
return lines.join('\n') + '\n'
}
/**
* Parse a pointer string back to file info
*
* @param {string} text - Pointer file content
* @returns {Object} { version, oid, size, chunkCount, chunks, manifestHash }
*/
export function parse (text) {
const lines = text.trim().split('\n')
// Minimum viable pointer is 3 lines: header + oid + size. Older
// serializers (pre-chunk-metadata) produced this 3-line shape; the
// chunks line + inline chunk list / manifest came later. parsers must
// accept the legacy form so pre-chunks pointers in existing repos still
// resolve via oid (the only field needed for fetch-by-oid).
if (lines.length < 3 || lines[0] !== HEADER) {
throw new Error('Invalid hyper-overlay pointer')
}
const oidMatch = lines[1].match(/^oid blake2b:([0-9a-f]{64})$/)
if (!oidMatch) throw new Error('Invalid pointer: bad oid line')
const sizeMatch = lines[2].match(/^size (\d+)$/)
if (!sizeMatch) throw new Error('Invalid pointer: bad size line')
// chunks line is optional for legacy compat; default to chunkCount 0.
let chunkCount = 0
if (lines.length >= 4) {
const chunksMatch = lines[3].match(/^chunks (\d+)$/)
if (chunksMatch) chunkCount = parseInt(chunksMatch[1], 10)
// If line 3 isn't a `chunks N` declaration, treat as legacy 3-line
// pointer (chunks omitted) and let further-line parsing decide via
// its own startsWith checks below.
}
const result = {
version: VERSION,
oid: oidMatch[1],
size: parseInt(sizeMatch[1], 10),
chunkCount,
chunks: null,
manifestHash: null
}
if (lines.length > 4) {
const line4 = lines[4]
if (line4.startsWith('manifest blake2b:')) {
result.manifestHash = line4.slice('manifest blake2b:'.length)
} else if (line4.startsWith('blake2b:')) {
// Inline chunk list
result.chunks = []
for (let i = 4; i < lines.length; i++) {
const parts = lines[i].split(' ')
if (parts.length !== 2 || !parts[0].startsWith('blake2b:')) break
result.chunks.push({
hash: parts[0].slice('blake2b:'.length),
size: parseInt(parts[1], 10)
})
}
}
}
return result
}
/**
* Check if a string is a hyper-overlay pointer
*
* @param {string|Buffer} content
* @returns {boolean}
*/
export function isPointer (content) {
const text = typeof content === 'string' ? content : content.toString('utf-8')
return text.startsWith(HEADER + '\n')
}
export { INLINE_MAX }