OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-overlay / lib / pointer.js

135 lines · javascript ref:
/**
 * 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 }