OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-overlay / lib / ignore.js

157 lines · javascript ref:
/**
 * Ignore — .overlayignore file parser + default deny-list
 *
 * Gitignore-style pattern matching:
 *   - Lines starting with # are comments
 *   - Blank lines are skipped
 *   - Patterns ending with / match directories only
 *   - * matches anything except /
 *   - ** matches anything including /
 *   - ! negates a pattern (re-include)
 *   - Leading / anchors to the root
 *
 * Default deny-list is always active (security patterns).
 * .overlayignore in a sync dir adds per-directory rules.
 */

import fs from '#fs'
import path from '#path'

// Always blocked — security-critical patterns
const DENY_LIST = [
  '.env', '.env.*',
  '.ssh', '.ssh/**',
  '.gnupg', '.gnupg/**',
  '*.key', '*.pem', '*.p12', '*.pfx',
  'credentials*', '*secret*',
  '.git', '.git/**',
  'node_modules', 'node_modules/**',
  '.DS_Store', 'Thumbs.db',
  '*.overlay-partial',
  '.overlayignore'
]

/**
 * Load ignore patterns from a directory's .overlayignore file
 * @param {string} dir - directory to check for .overlayignore
 * @returns {string[]} patterns from the file (empty if no file)
 */
export function loadIgnoreFile (dir) {
  const filePath = path.join(dir, '.overlayignore')
  try {
    const content = fs.readFileSync(filePath, 'utf-8')
    return parseIgnoreFile(content)
  } catch {
    return []
  }
}

/**
 * Parse ignore file content into patterns
 * @param {string} content
 * @returns {string[]}
 */
export function parseIgnoreFile (content) {
  return content
    .split('\n')
    .map(line => line.trim())
    .filter(line => line && !line.startsWith('#'))
}

/**
 * Create a matcher function from patterns
 * @param {string[]} extraPatterns - user patterns from .overlayignore
 * @param {object} [opts]
 * @param {boolean} [opts.noDenyList] - if true, skip the security-deny list
 *   (publish-time use case: caller already handles node_modules/.git via
 *   rootSkip and may want to publish .env.example intentionally).
 * @returns {(filePath: string, baseName: string) => boolean} returns true if file should be ignored
 */
export function createMatcher (extraPatterns = [], opts = {}) {
  const patterns = opts.noDenyList
    ? [...extraPatterns]
    : [...DENY_LIST, ...extraPatterns]
  const negations = []
  const positives = []

  for (const p of patterns) {
    if (p.startsWith('!')) {
      negations.push(p.slice(1))
    } else {
      positives.push(p)
    }
  }

  return function isIgnored (filePath, baseName) {
    baseName = baseName || path.basename(filePath)

    // Check negations first — if explicitly included, allow it
    for (const pattern of negations) {
      if (matchPattern(baseName, pattern) || matchPattern(filePath, pattern)) {
        return false
      }
    }

    // Check positive patterns
    for (const pattern of positives) {
      if (matchPattern(baseName, pattern) || matchPattern(filePath, pattern)) {
        return true
      }
    }

    return false
  }
}

/**
 * Simple glob pattern matching
 * Supports: *, **, ?, literal characters
 * @param {string} str - string to test
 * @param {string} pattern - glob pattern
 * @returns {boolean}
 */
function matchPattern (str, pattern) {
  // Remove trailing / (directory marker — we treat all as paths)
  pattern = pattern.replace(/\/$/, '')

  // Exact match
  if (str === pattern) return true

  // Convert glob to regex
  let regex = ''
  let i = 0
  while (i < pattern.length) {
    const c = pattern[i]
    if (c === '*') {
      if (pattern[i + 1] === '*') {
        regex += '.*' // ** matches everything
        i += 2
        if (pattern[i] === '/') i++ // skip separator after **
        continue
      }
      regex += '[^/]*' // * matches non-separator
    } else if (c === '?') {
      regex += '[^/]'
    } else if (c === '.') {
      regex += '\\.'
    } else {
      regex += c
    }
    i++
  }

  try {
    return new RegExp('^' + regex + '$').test(str)
  } catch {
    return false
  }
}

/**
 * Get the default deny list
 * @returns {string[]}
 */
export function getDefaultDenyList () {
  return [...DENY_LIST]
}