Ctrl K
ComunidadGuías…

lib/reply-proposals.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Local, untrusted status proposals. The producer never gets a tracker writer.
import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { canonicalizeTrackerPath } from '../path-resolver.mjs';

const MAX_FILE_BYTES = 64 * 1024;
const CONTROLS = /[\x00-\x1f\x7f-\x9f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u;
const HASH = /^[a-f0-9]{64}$/;

export function proposalReceipt(source) {
  const key = JSON.stringify([source.kind, source.account_id, source.message_id]);
  return `[reply-proposal:${createHash('sha256').update(key).digest('hex')}]`;
}

export function hasProposalReceipt(apps, receipt) {
  return apps.some(app => app.notes.split(';').some(note => note.trim() === receipt));
}

function fields(value, names) {
  return value !== null && typeof value === 'object' && !Array.isArray(value)
    && Object.keys(value).length === names.length
    && names.every(name => Object.hasOwn(value, name));
}

function text(value, max = 1000, empty = false) {
  return typeof value === 'string' && value.length <= max
    && (empty || value.trim().length > 0) && !CONTROLS.test(value);
}

// Diagnostics are also untrusted: JSON.parse errors can include bytes from a
// malformed file, and JSON.stringify alone does not escape bidi controls.
function diagnostic(value) {
  return JSON.stringify(String(value)).replace(/[\x00-\x1f\x7f-\x9f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu,
    char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`);
}

function validSource(source) {
  return fields(source, ['kind', 'account_id', 'message_id'])
    && source.kind === 'gmail' && typeof source.account_id === 'string'
    && source.account_id.length === 64 && HASH.test(source.account_id) && typeof source.message_id === 'string'
    && !CONTROLS.test(source.message_id) && /^[A-Za-z0-9_-]{1,256}$/.test(source.message_id);
}

function validate(row, trackerPath, states) {
  if (!fields(row, ['schema_version', 'source', 'tracker_path', 'application', 'from_status', 'to_status', 'evidence'])
      || row.schema_version !== 1) return 'invalid schema or fields';
  if (!validSource(row.source)) return 'invalid source identity';
  if (!text(row.tracker_path, 4096) || !path.isAbsolute(row.tracker_path)
      || row.tracker_path !== canonicalizeTrackerPath(trackerPath)) return 'foreign or noncanonical tracker path';
  const app = row.application;
  if (!fields(app, ['num', 'date', 'company', 'role', 'report'])
      || !Number.isSafeInteger(app.num) || app.num <= 0
      || !text(app.date, 100, true) || !text(app.company) || !text(app.role)
      || !text(app.report, 2000, true)) return 'invalid application identity';
  if (!states.some(s => s.id === row.from_status) || !states.some(s => s.id === row.to_status)
      || row.from_status === row.to_status) return 'expected distinct canonical status ids';
  if (!text(row.evidence, 2000)) return 'invalid evidence excerpt';
  return null;
}

// Fixed field order makes semantically identical JSON deduplicate regardless of
// whitespace or producer object-key order. Source identity is deliberately NOT
// tied to the proposed transition: changing a target cannot replay accepted mail.
function payload(row) {
  const a = row.application;
  return JSON.stringify([row.tracker_path, a.num, a.date, a.company, a.role, a.report,
    row.from_status, row.to_status, row.evidence]);
}

/** Read one JSON object per file, without modifying drops or tracker data. */
export function readReplyProposals(dataRoot, trackerPath, apps, states) {
  const directory = path.join(dataRoot, 'data', 'reply-proposals');
  const warnings = [];
  const proposals = new Map();
  const blocked = new Set();
  let entries;
  try {
    const stat = fs.lstatSync(directory);
    if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('expected a regular directory');
    entries = fs.readdirSync(directory).filter(name => name.endsWith('.json')).sort();
  } catch (err) {
    if (err.code !== 'ENOENT') warnings.push(`Cannot read reply proposals: ${diagnostic(err.message)}`);
    return { recommendations: [], warnings };
  }

  for (const name of entries) {
    let fd;
    let row;
    try {
      const filename = path.join(directory, name);
      const stat = fs.lstatSync(filename);
      if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('expected a regular file');
      fd = fs.openSync(filename, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
      const opened = fs.fstatSync(fd);
      if (!opened.isFile() || opened.size > MAX_FILE_BYTES) throw new Error('file exceeds 64 KiB or is not regular');
      // Read at most the contract limit even if a producer writes concurrently.
      const buffer = Buffer.alloc(MAX_FILE_BYTES + 1);
      const bytes = fs.readSync(fd, buffer, 0, buffer.length, 0);
      if (bytes > MAX_FILE_BYTES) throw new Error('file exceeds 64 KiB');
      row = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, bytes)));
      const error = validate(row, trackerPath, states);
      if (error) throw new Error(error);
      const receipt = proposalReceipt(row.source);
      const previous = proposals.get(receipt);
      if (previous && payload(previous) !== payload(row)) {
        blocked.add(receipt);
        throw new Error('conflicting payloads for the same source identity');
      }
      proposals.set(receipt, row);
    } catch (err) {
      // An invalid duplicate must not leave its valid sibling actionable.
      if (validSource(row?.source)) blocked.add(proposalReceipt(row.source));
      warnings.push(`Skipped proposal ${diagnostic(name)}: ${diagnostic(err.message)}`);
    } finally {
      if (fd !== undefined) fs.closeSync(fd);
    }
  }

  const recommendations = [];
  for (const [receipt, proposal] of proposals) {
    if (blocked.has(receipt) || hasProposalReceipt(apps, receipt)) continue;
    const matches = apps.filter(app => app.num === proposal.application.num);
    const app = matches[0];
    const from = states.find(s => s.id === proposal.from_status).label;
    const to = states.find(s => s.id === proposal.to_status).label;
    let reason;
    if (matches.length !== 1) reason = 'missing or ambiguous tracker row';
    else if (!Object.entries(proposal.application).every(([key, value]) => app[key] === value)) reason = 'application identity changed';
    else if (app.status === to) continue;
    else if (app.status !== from) reason = 'stale from-status';
    if (reason) {
      warnings.push(`Skipped proposal for #${proposal.application.num}: ${reason}`);
      continue;
    }
    recommendations.push({
      num: app.num, company: app.company, role: app.role,
      oldStatus: from, newStatus: to, snapshot: app,
      proposals: [{ receipt, evidence: proposal.evidence, source: proposal.source }],
    });
  }
  return { recommendations, warnings };
}