// auto-cleaner-traversal.test.mjs — Security regression for F-2 (HIGH, arbitrary file write).
//
// auto-cleaner's applyFixes() historically did `resolve(targetPath, f.file)` with no
// containment check, then wrote the modified content back to that path. `f.file` comes
// from findings JSON — untrusted scanned-repo filenames, or a fully attacker-chosen
// `--findings` file. A finding with `file: "../secret.txt"` therefore let the cleaner
// modify (overwrite) a file OUTSIDE the scanned tree.
//
// This test places an auto-fixable file outside the target dir and points an auto-tier
// finding at it via "../secret.txt". It must FAIL while the sink is uncontained (the
// outside file gets rewritten) and PASS once applyFixes refuses paths that escape the
// target (prefix containment), reporting them as skipped instead of writing.

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { applyFixes } from '../../scanners/auto-cleaner.mjs';

const ZW = '​'; // zero-width space — strip_zero_width will remove it

describe('auto-cleaner path-traversal regression (F-2)', () => {
  it('refuses to write a finding whose file path escapes the target directory', async () => {
    // root/                     <- mkdtemp parent
    //   secret.txt              <- the escape target, OUTSIDE the scanned tree
    //   scan-target/            <- the directory actually passed to the cleaner
    const root = mkdtempSync(join(tmpdir(), 'auto-cleaner-traversal-'));
    const target = join(root, 'scan-target');
    const escape = join(root, 'secret.txt');
    try {
      mkdirSync(target, { recursive: true });

      // The escape file carries a zero-width char so the (auto-tier) strip_zero_width
      // op WOULD change it — proving a write actually reaches outside the tree if
      // containment is missing.
      const original = `secret${ZW}value\n`;
      writeFileSync(escape, original, 'utf-8');

      // Untrusted finding: classified 'auto' (UNI zero-width), file traverses up and out.
      const findings = [{
        id: 'TRAVERSAL-1',
        scanner: 'UNI',
        title: 'Zero-width characters detected',
        severity: 'high',
        file: '../secret.txt',
      }];

      resetCounter();
      const { fixes } = await applyFixes(target, findings, /* dryRun */ false);

      // The file outside the target must be byte-for-byte untouched.
      assert.equal(
        readFileSync(escape, 'utf-8'),
        original,
        'PATH TRAVERSAL: auto-cleaner rewrote a file outside the scanned target directory',
      );

      // And the escaping finding must be surfaced as refused/skipped, never applied.
      const escaped = fixes.find(f => f.file === '../secret.txt');
      assert.ok(escaped, 'the escaping finding should be reported');
      assert.notEqual(escaped.status, 'applied', 'escaping finding must not be applied');
    } finally {
      rmSync(root, { recursive: true, force: true });
    }
  });

  it('still applies fixes to files contained within the target directory', async () => {
    const target = mkdtempSync(join(tmpdir(), 'auto-cleaner-contained-'));
    try {
      const inside = join(target, 'inside.md');
      writeFileSync(inside, `---\ntitle: x${ZW}y\n---\nbody\n`, 'utf-8');

      const findings = [{
        id: 'CONTAINED-1',
        scanner: 'UNI',
        title: 'Zero-width characters detected',
        severity: 'high',
        file: 'inside.md',
      }];

      resetCounter();
      const { fixes } = await applyFixes(target, findings, /* dryRun */ false);

      assert.ok(
        !readFileSync(inside, 'utf-8').includes(ZW),
        'contained file should have been cleaned',
      );
      const applied = fixes.find(f => f.file === 'inside.md' && f.status === 'applied');
      assert.ok(applied, 'a contained auto-fix should be applied');
    } finally {
      rmSync(target, { recursive: true, force: true });
    }
  });
});
