a3s-acl 0.3.0

Parser and generator for the A3S Agent Configuration Language
Documentation
const { createHash } = require('node:crypto');
const { generate } = require('../generator/generator.js');
const { normalizeSchema } = require('../schema/definition.js');

const CANONICAL_DIGEST_ALGORITHM = 'sha256';

class CanonicalError extends Error {
  constructor(code, message) {
    super(message);
    this.name = 'CanonicalError';
    this.code = code;
  }
}

function canonicalBytes(document) {
  validateDocument(document);
  return canonicalBytesFromValidated(document);
}

function canonicalBytesWithSchema(document, schema) {
  const normalizedSchema = normalizeSchema(schema, '$schema', new Set());
  validateDocument(document);
  return canonicalBytesFromValidated(normalizeDocument(document, normalizedSchema));
}

function canonicalBytesFromValidated(document) {
  const generated = generate(document).replace(/\n+$/u, '');
  return Buffer.from(`${generated}\n`, 'utf8');
}

function canonicalDigest(document) {
  const bytes = canonicalBytes(document);
  return digestBytes(bytes);
}

function canonicalDigestWithSchema(document, schema) {
  const bytes = canonicalBytesWithSchema(document, schema);
  return digestBytes(bytes);
}

function digestBytes(bytes) {
  return `${CANONICAL_DIGEST_ALGORITHM}:${createHash(CANONICAL_DIGEST_ALGORITHM)
    .update(bytes)
    .digest('hex')}`;
}

function normalizeDocument(document, schema) {
  return {
    blocks: normalizeBlocks(document.blocks, schema, true),
  };
}

function normalizeBlocks(blocks, schema, documentRoot) {
  const normalized = [];
  const unorderedPositions = new Map();

  for (const [index, block] of blocks.entries()) {
    const rootAttribute = documentRoot && bareAttribute(block);
    const rule = rootAttribute ? undefined : schema.blocks.get(block.name);
    normalized.push(rule ? normalizeBlock(block, rule) : block);
    if (rule?.unordered) {
      const positions = unorderedPositions.get(block.name);
      if (positions) positions.push(index);
      else unorderedPositions.set(block.name, [index]);
    }
  }

  for (const positions of unorderedPositions.values()) {
    if (positions.length < 2) continue;
    const matching = positions
      .map((index) => normalized[index])
      .map((block) => ({
        block,
        key: canonicalBytesFromValidated({ blocks: [block] }),
      }))
      .sort((left, right) => Buffer.compare(left.key, right.key))
      .map(({ block }) => block);
    for (const [offset, index] of positions.entries()) {
      normalized[index] = matching[offset];
    }
  }
  return normalized;
}

function normalizeBlock(block, schema) {
  return {
    ...block,
    blocks: normalizeBlocks(block.blocks, schema.body, false),
  };
}

function bareAttribute(block) {
  return block.labels.length === 0
    && block.blocks.length === 0
    && block.attributes.size === 1
    && block.attributes.has(block.name);
}

function validateDocument(document) {
  for (const block of document.blocks) {
    validateBlock(block);
  }
}

function validateBlock(block) {
  validateIdentifier(block.name);
  for (const label of block.labels) {
    validateScalarString(label);
  }
  for (const [key, value] of block.attributes) {
    validateIdentifier(key);
    validateValue(value);
  }
  for (const nested of block.blocks) {
    validateBlock(nested);
  }
}

function validateValue(value) {
  switch (value.kind) {
    case 'Number':
      if (!Number.isFinite(value.value)) {
        throw new CanonicalError(
          'acl.canonical.non_finite_number',
          'ACL canonicalization requires finite numbers'
        );
      }
      break;
    case 'List':
      for (const item of value.items) validateValue(item);
      break;
    case 'Object':
      for (const [key, item] of value.pairs) {
        validateIdentifier(key);
        validateValue(item);
      }
      break;
    case 'Call':
      validateIdentifier(value.name);
      for (const argument of value.args) validateValue(argument);
      break;
    case 'String':
      validateScalarString(value.value);
      break;
    case 'Bool':
    case 'Null':
      break;
  }
}

function validateScalarString(value) {
  for (let index = 0; index < value.length; index += 1) {
    const codeUnit = value.charCodeAt(index);
    if (codeUnit >= 0xD800 && codeUnit <= 0xDBFF) {
      const next = value.charCodeAt(index + 1);
      if (!(next >= 0xDC00 && next <= 0xDFFF)) {
        throw new CanonicalError(
          'acl.canonical.invalid_unicode',
          'ACL canonicalization requires Unicode scalar values'
        );
      }
      index += 1;
    } else if (codeUnit >= 0xDC00 && codeUnit <= 0xDFFF) {
      throw new CanonicalError(
        'acl.canonical.invalid_unicode',
        'ACL canonicalization requires Unicode scalar values'
      );
    }
  }
}

function validateIdentifier(identifier) {
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(identifier)) {
    throw new CanonicalError(
      'acl.canonical.unsupported_identifier',
      'ACL canonicalization requires portable ASCII identifiers'
    );
  }
}

module.exports = {
  CANONICAL_DIGEST_ALGORITHM,
  CanonicalError,
  canonicalBytes,
  canonicalBytesWithSchema,
  canonicalDigest,
  canonicalDigestWithSchema,
};