fix(cv): harden document parsing
Upgrade and hash-lock upload-facing parser dependencies, reject resource-heavy or mismatched inputs, remove unsafe backend binary fallbacks, and prevent internal parser failures from leaking to users.
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
|
using System.Text;
|
||||||
using JobTrackerApi.Controllers;
|
using JobTrackerApi.Controllers;
|
||||||
using JobTrackerApi.Models;
|
using JobTrackerApi.Models;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
@@ -241,7 +242,7 @@ public sealed class CvExtractionCoverageTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Docx_fallback_preserves_paragraph_and_table_boundaries()
|
public async Task Binary_document_fallback_is_disabled()
|
||||||
{
|
{
|
||||||
await using var package = new MemoryStream();
|
await using var package = new MemoryStream();
|
||||||
using (var archive = new ZipArchive(package, ZipArchiveMode.Create, leaveOpen: true))
|
using (var archive = new ZipArchive(package, ZipArchiveMode.Create, leaveOpen: true))
|
||||||
@@ -263,14 +264,23 @@ public sealed class CvExtractionCoverageTests
|
|||||||
}
|
}
|
||||||
package.Position = 0;
|
package.Position = 0;
|
||||||
var upload = new FormFile(package, 0, package.Length, "file", "sanitized.docx");
|
var upload = new FormFile(package, 0, package.Length, "file", "sanitized.docx");
|
||||||
var method = typeof(ProfileCvController).GetMethod("ExtractTextAsync", BindingFlags.NonPublic | BindingFlags.Static)!;
|
var method = typeof(ProfileCvController).GetMethod("ExtractPlainTextAsync", BindingFlags.NonPublic | BindingFlags.Static)!;
|
||||||
var task = Assert.IsAssignableFrom<Task<string>>(method.Invoke(null, new object[] { upload, ".docx" }));
|
var task = Assert.IsAssignableFrom<Task<string>>(method.Invoke(null, new object[] { upload, ".docx", CancellationToken.None }));
|
||||||
var extracted = await task;
|
var extracted = await task;
|
||||||
|
|
||||||
Assert.Contains("Technical Skills\n", extracted);
|
Assert.Empty(extracted);
|
||||||
Assert.Contains("Backend | C#, .NET", extracted);
|
}
|
||||||
Assert.Contains("DevOps | Docker, Linux", extracted);
|
|
||||||
Assert.Contains("\n\nExample Engineer\n- Built reliable services", extracted);
|
[Fact]
|
||||||
|
public async Task Plain_text_fallback_reads_bounded_utf8()
|
||||||
|
{
|
||||||
|
const string source = "# Ada Lovelace\n\n## Skills\nC#\nSQL";
|
||||||
|
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(source));
|
||||||
|
var upload = new FormFile(stream, 0, stream.Length, "file", "resume.md");
|
||||||
|
var method = typeof(ProfileCvController).GetMethod("ExtractPlainTextAsync", BindingFlags.NonPublic | BindingFlags.Static)!;
|
||||||
|
var task = Assert.IsAssignableFrom<Task<string>>(method.Invoke(null, new object[] { upload, ".md", CancellationToken.None }));
|
||||||
|
|
||||||
|
Assert.Equal(source, await task);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static StructuredCvProfile InvokeProfileBuilder(string markdown)
|
private static StructuredCvProfile InvokeProfileBuilder(string markdown)
|
||||||
|
|||||||
@@ -1112,6 +1112,70 @@ public sealed class ProfileCvControllerTests
|
|||||||
Assert.Equal("pending_review", run.Status);
|
Assert.Equal("pending_review", run.Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Binary_upload_does_not_fall_back_to_in_process_parsing()
|
||||||
|
{
|
||||||
|
var user = new ApplicationUser { Id = "user-1" };
|
||||||
|
var userManager = CreateUserManager();
|
||||||
|
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||||
|
ConfigureWorkerUser(userManager, user);
|
||||||
|
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||||
|
var aiService = new Mock<ISummarizerService>();
|
||||||
|
aiService
|
||||||
|
.Setup(x => x.ExtractTextAsync(It.IsAny<Stream>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((AiTextExtractionResult?)null);
|
||||||
|
|
||||||
|
await using var db = CreateDb();
|
||||||
|
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
|
||||||
|
var bytes = Encoding.UTF8.GetBytes("not a safe DOCX package");
|
||||||
|
var file = new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "resume.docx")
|
||||||
|
{
|
||||||
|
Headers = new HeaderDictionary(),
|
||||||
|
ContentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.IsType<AcceptedResult>(await controller.Upload(file));
|
||||||
|
var run = await db.CvExtractionRuns.SingleAsync();
|
||||||
|
var outcome = Assert.IsType<CvProcessingOutcome>(await controller.ProcessQueuedRunAsync(run.Id, CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.False(outcome.Succeeded);
|
||||||
|
Assert.Equal("The document extraction service could not read this CV safely.", outcome.FailureMessage);
|
||||||
|
Assert.Equal("failed", run.Status);
|
||||||
|
Assert.Equal(outcome.FailureMessage, run.ErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unexpected_extraction_errors_are_not_exposed_to_users()
|
||||||
|
{
|
||||||
|
var user = new ApplicationUser { Id = "user-1" };
|
||||||
|
var userManager = CreateUserManager();
|
||||||
|
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||||
|
ConfigureWorkerUser(userManager, user);
|
||||||
|
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||||
|
var aiService = new Mock<ISummarizerService>();
|
||||||
|
aiService
|
||||||
|
.Setup(x => x.ExtractTextAsync(It.IsAny<Stream>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ThrowsAsync(new IOException(@"Secret parser path C:\private\cv.docx"));
|
||||||
|
|
||||||
|
await using var db = CreateDb();
|
||||||
|
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
|
||||||
|
var source = Encoding.UTF8.GetBytes("# Resume");
|
||||||
|
var file = new FormFile(new MemoryStream(source), 0, source.Length, "file", "resume.md")
|
||||||
|
{
|
||||||
|
Headers = new HeaderDictionary(),
|
||||||
|
ContentType = "text/markdown"
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.IsType<AcceptedResult>(await controller.Upload(file));
|
||||||
|
var run = await db.CvExtractionRuns.SingleAsync();
|
||||||
|
var outcome = Assert.IsType<CvProcessingOutcome>(await controller.ProcessQueuedRunAsync(run.Id, CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.False(outcome.Succeeded);
|
||||||
|
Assert.Equal("CV processing failed unexpectedly. Please try again.", outcome.FailureMessage);
|
||||||
|
Assert.Equal(outcome.FailureMessage, run.ErrorMessage);
|
||||||
|
Assert.DoesNotContain("private", outcome.FailureMessage, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Normalized_markdown_parse_preserves_real_estate_job_and_language_levels()
|
public void Normalized_markdown_parse_preserves_real_estate_job_and_language_levels()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ using System.Security.Cryptography;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Xml.Linq;
|
|
||||||
using JobTrackerApi.Data;
|
using JobTrackerApi.Data;
|
||||||
using JobTrackerApi.Services;
|
using JobTrackerApi.Services;
|
||||||
using JobTrackerApi.Models;
|
using JobTrackerApi.Models;
|
||||||
@@ -1352,78 +1351,29 @@ public sealed partial class ProfileCvController : ControllerBase
|
|||||||
|| Regex.IsMatch(text, @"(?im)^\s*#\s*(Contact|Professional Summary|Summary|Work Experience|Experience|Education|Skills|Languages|Interests)");
|
|| Regex.IsMatch(text, @"(?im)^\s*#\s*(Contact|Professional Summary|Summary|Work Experience|Experience|Education|Skills|Languages|Interests)");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<string> ExtractTextAsync(IFormFile file, string extension)
|
private static async Task<string> ExtractPlainTextAsync(IFormFile file, string extension, CancellationToken cancellationToken)
|
||||||
{
|
|
||||||
if (string.Equals(extension, ".txt", StringComparison.OrdinalIgnoreCase) || string.Equals(extension, ".md", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
|
if (!string.Equals(extension, ".txt", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
!string.Equals(extension, ".md", StringComparison.OrdinalIgnoreCase)) return string.Empty;
|
||||||
|
|
||||||
using var stream = file.OpenReadStream();
|
using var stream = file.OpenReadStream();
|
||||||
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
|
using var reader = new StreamReader(
|
||||||
return (await reader.ReadToEndAsync()).Trim();
|
stream,
|
||||||
}
|
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true),
|
||||||
|
detectEncodingFromByteOrderMarks: true);
|
||||||
await using var memory = new MemoryStream();
|
var buffer = new char[8192];
|
||||||
await file.CopyToAsync(memory);
|
var result = new StringBuilder(capacity: (int)Math.Min(file.Length, 64 * 1024));
|
||||||
var bytes = memory.ToArray();
|
while (true)
|
||||||
|
|
||||||
if (string.Equals(extension, ".pdf", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
var raw = Encoding.Latin1.GetString(bytes);
|
var read = await reader.ReadAsync(buffer.AsMemory(), cancellationToken);
|
||||||
var textMatches = Regex.Matches(raw, @"\((.*?)\)Tj", RegexOptions.Singleline)
|
if (read == 0) break;
|
||||||
.Select(match => match.Groups[1].Value)
|
if (result.Length + read > 200_000)
|
||||||
.Concat(Regex.Matches(raw, @"\[(.*?)\]TJ", RegexOptions.Singleline)
|
|
||||||
.SelectMany(match => Regex.Matches(match.Groups[1].Value, @"\((.*?)\)", RegexOptions.Singleline).Select(x => x.Groups[1].Value)))
|
|
||||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
||||||
.Select(value => Regex.Unescape(value))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var joined = textMatches.Count > 0 ? string.Join(" ", textMatches) : raw;
|
|
||||||
var scrubbed = Regex.Replace(joined, @"[\x00-\x08\x0B\x0C\x0E-\x1F]", " ");
|
|
||||||
return Regex.Replace(scrubbed, @"\s+", " ").Trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.Equals(extension, ".docx", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
using var archive = new System.IO.Compression.ZipArchive(new MemoryStream(bytes), System.IO.Compression.ZipArchiveMode.Read, leaveOpen: false);
|
throw new InvalidOperationException("The extracted CV text is too large to process safely.");
|
||||||
var entry = archive.GetEntry("word/document.xml");
|
}
|
||||||
if (entry is null) return string.Empty;
|
result.Append(buffer, 0, read);
|
||||||
using var entryStream = entry.Open();
|
|
||||||
using var reader = new StreamReader(entryStream, Encoding.UTF8);
|
|
||||||
var xml = await reader.ReadToEndAsync();
|
|
||||||
var document = XDocument.Parse(xml, LoadOptions.PreserveWhitespace);
|
|
||||||
XNamespace word = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
|
||||||
var body = document.Root?.Element(word + "body");
|
|
||||||
if (body is null) return string.Empty;
|
|
||||||
|
|
||||||
static string Text(XElement element, XNamespace ns) => string.Concat(
|
|
||||||
element.Descendants(ns + "t").Select(node => node.Value));
|
|
||||||
|
|
||||||
var blocks = new List<string>();
|
|
||||||
foreach (var block in body.Elements())
|
|
||||||
{
|
|
||||||
if (block.Name == word + "p")
|
|
||||||
{
|
|
||||||
var paragraph = Text(block, word).Trim();
|
|
||||||
if (paragraph.Length == 0) continue;
|
|
||||||
var style = block.Element(word + "pPr")?.Element(word + "pStyle")?.Attribute(word + "val")?.Value ?? string.Empty;
|
|
||||||
if (style.Contains("Role", StringComparison.OrdinalIgnoreCase) && blocks.Count > 0) blocks.Add(string.Empty);
|
|
||||||
blocks.Add(style.Contains("Bullet", StringComparison.OrdinalIgnoreCase) ? $"- {paragraph}" : paragraph);
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (block.Name != word + "tbl") continue;
|
return result.ToString().Trim();
|
||||||
foreach (var row in block.Elements(word + "tr"))
|
|
||||||
{
|
|
||||||
var cells = row.Elements(word + "tc")
|
|
||||||
.Select(cell => string.Join(" ", cell.Elements(word + "p").Select(paragraph => Text(paragraph, word).Trim()).Where(value => value.Length > 0)))
|
|
||||||
.Where(value => value.Length > 0)
|
|
||||||
.ToList();
|
|
||||||
if (cells.Count > 0) blocks.Add(string.Join(" | ", cells));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return string.Join("\n", blocks).Trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
return string.Empty;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,11 +231,15 @@ public sealed partial class ProfileCvController : ControllerBase
|
|||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(text))
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
{
|
{
|
||||||
text = (await ExtractTextAsync(file, extension)).Trim();
|
text = (await ExtractPlainTextAsync(file, extension, cancellationToken)).Trim();
|
||||||
}
|
}
|
||||||
if (string.IsNullOrWhiteSpace(text))
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("The uploaded CV file could not be read or was empty.");
|
throw new InvalidOperationException(
|
||||||
|
string.Equals(extension, ".txt", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(extension, ".md", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? "The uploaded CV file could not be read or was empty."
|
||||||
|
: "The document extraction service could not read this CV safely.");
|
||||||
}
|
}
|
||||||
|
|
||||||
text = RepairKnownMojibake(text);
|
text = RepairKnownMojibake(text);
|
||||||
@@ -506,8 +510,12 @@ public sealed partial class ProfileCvController : ControllerBase
|
|||||||
{
|
{
|
||||||
var generationFailure = ex as AiGenerationException;
|
var generationFailure = ex as AiGenerationException;
|
||||||
var retryable = generationFailure?.Retryable == true;
|
var retryable = generationFailure?.Retryable == true;
|
||||||
|
var failureMessage = generationFailure?.Message
|
||||||
|
?? (ex is InvalidOperationException
|
||||||
|
? ex.Message
|
||||||
|
: "CV processing failed unexpectedly. Please try again.");
|
||||||
run.Status = retryable ? "queued" : "failed";
|
run.Status = retryable ? "queued" : "failed";
|
||||||
run.ErrorMessage = ex.Message;
|
run.ErrorMessage = failureMessage;
|
||||||
run.CompletedAtUtc = retryable ? null : DateTimeOffset.UtcNow;
|
run.CompletedAtUtc = retryable ? null : DateTimeOffset.UtcNow;
|
||||||
await _db.SaveChangesAsync(cancellationToken);
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
if (!retryable)
|
if (!retryable)
|
||||||
@@ -519,7 +527,7 @@ public sealed partial class ProfileCvController : ControllerBase
|
|||||||
return new CvProcessingOutcome(
|
return new CvProcessingOutcome(
|
||||||
false,
|
false,
|
||||||
generationFailure?.Category ?? "cv_processing_failed",
|
generationFailure?.Category ?? "cv_processing_failed",
|
||||||
ex.Message,
|
failureMessage,
|
||||||
retryable,
|
retryable,
|
||||||
generationFailure?.Provider,
|
generationFailure?.Provider,
|
||||||
generationFailure?.Model,
|
generationFailure?.Model,
|
||||||
|
|||||||
@@ -221,3 +221,4 @@ Output was reduced to filenames and commit counts. The token artifact appears un
|
|||||||
| V-187 | Focused public-CV/rate-key tests, focused authenticated-preview Jest, full backend, production frontend build and tracked-tree secret-pattern scan | Repository root / `job-tracker-ui` | Close the remaining low-risk public PDF, preview sandbox and tracked JWT hardening findings | PASS — backend focused 4/4 and full 681/681; preview Jest 15/15; production build passes. Two clients receive independent same-slug PDF partitions, both authenticated preview iframes disable scripts with a sandbox, and the values-suppressed tracked-tree scan finds no JWT/private-key pattern outside audit/operations records | No stress test, production request, history rewrite or Data Protection inspection. A coordinated history rewrite remains outside this change | JT-023/JT-025 current-tree gaps closed; expired JT-020 artifact removed from current tree |
|
| V-187 | Focused public-CV/rate-key tests, focused authenticated-preview Jest, full backend, production frontend build and tracked-tree secret-pattern scan | Repository root / `job-tracker-ui` | Close the remaining low-risk public PDF, preview sandbox and tracked JWT hardening findings | PASS — backend focused 4/4 and full 681/681; preview Jest 15/15; production build passes. Two clients receive independent same-slug PDF partitions, both authenticated preview iframes disable scripts with a sandbox, and the values-suppressed tracked-tree scan finds no JWT/private-key pattern outside audit/operations records | No stress test, production request, history rewrite or Data Protection inspection. A coordinated history rewrite remains outside this change | JT-023/JT-025 current-tree gaps closed; expired JT-020 artifact removed from current tree |
|
||||||
| V-188 | 75-user query-count fixture, 205-message/two-tenant pagination fixture, focused/full backend and frontend, optimized build | Repository root / `job-tracker-ui` | Remove JT-021's confirmed N+1 and silent 200-message ceiling without breaking old clients | PASS — admin list performs two reads independent of 75 users; page 3 returns the final 5 of 205 owned messages and excludes another tenant. Focused backend 9/9, correspondence UI 15/15, backend 683/683, frontend 58 suites/239 tests and build pass. UI page navigation and filter reset are covered; the legacy inbox endpoint remains unchanged | Synthetic SQLite/InMemory/JSDOM only; no provider mailbox, production dataset or p95 load test. Current page linked/inbound chips intentionally describe the visible page | JT-021 repository defects closed; provider/production capacity remains operational evidence |
|
| V-188 | 75-user query-count fixture, 205-message/two-tenant pagination fixture, focused/full backend and frontend, optimized build | Repository root / `job-tracker-ui` | Remove JT-021's confirmed N+1 and silent 200-message ceiling without breaking old clients | PASS — admin list performs two reads independent of 75 users; page 3 returns the final 5 of 205 owned messages and excludes another tenant. Focused backend 9/9, correspondence UI 15/15, backend 683/683, frontend 58 suites/239 tests and build pass. UI page navigation and filter reset are covered; the legacy inbox endpoint remains unchanged | Synthetic SQLite/InMemory/JSDOM only; no provider mailbox, production dataset or p95 load test. Current page linked/inbound chips intentionally describe the visible page | JT-021 repository defects closed; provider/production capacity remains operational evidence |
|
||||||
| V-189 | Fresh Python 3.12 virtual environments; `pytest -q`; `npm run lint`; full frontend Jest; `npm run build`; `npm audit --audit-level=moderate` | Repository root / `tools/summarizer` / `job-tracker-ui` | Restore the local Python test environment and establish a reproducible zero-warning frontend lint gate | PASS — Python 3.12.10; sidecar 26/26; ESLint zero findings; frontend 64 suites/272 tests; optimized build/TypeScript; npm audit zero vulnerabilities | Five existing SWIG deprecation warnings; Jest retains its documented force-exit/open-handle notice. ESLint 9 is intentionally pinned because the Next 16 React plugin is not ESLint 10 compatible | Local Python/frontend quality gates restored; parser dependency remediation proceeds separately under SEC-006 |
|
| V-189 | Fresh Python 3.12 virtual environments; `pytest -q`; `npm run lint`; full frontend Jest; `npm run build`; `npm audit --audit-level=moderate` | Repository root / `tools/summarizer` / `job-tracker-ui` | Restore the local Python test environment and establish a reproducible zero-warning frontend lint gate | PASS — Python 3.12.10; sidecar 26/26; ESLint zero findings; frontend 64 suites/272 tests; optimized build/TypeScript; npm audit zero vulnerabilities | Five existing SWIG deprecation warnings; Jest retains its documented force-exit/open-handle notice. ESLint 9 is intentionally pinned because the Next 16 React plugin is not ESLint 10 compatible | Local Python/frontend quality gates restored; parser dependency remediation proceeds separately under SEC-006 |
|
||||||
|
| V-190 | Compatible parser dependency resolution; Linux CPU hash lock; clean hash install; `pip-audit`; generated parser boundary tests; focused/full backend and sidecar suites; Docker daemon probe | Repository root / `tools/summarizer` | Remove reachable upload-parser advisories and close unsafe fallback/resource-boundary paths without parsing hostile fixtures | PASS/PARTIAL — FastAPI 0.141.1/Starlette 1.6.0, Pillow 12.3.0, pypdf 6.16.2 and python-multipart 0.0.32 resolve and install from hashes; upload-facing packages audit clear; parser 32/32, focused backend 51/51 and backend 719/719. Signature/container/page/pixel/decompression/output limits pass; backend binary fallback is removed and unexpected failures are sanitized | Docker daemon unavailable, so production image smoke/container assertions did not run. `pip-audit` reports 45 Torch/Transformers model-stack advisories tracked separately under JT-017. One Starlette/httpx test-client warning and five SWIG warnings remain | SEC-006 implemented locally; SEC-007 boundary work in progress, with child-process and container isolation still required |
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# SEC-006 parser dependency verification
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
The libraries reached by untrusted CV upload parsing were upgraded to compatible fixed releases and locked with hashes for the Linux CPU image. The dependency-only package is implemented locally but is not production-verified because the local Docker Desktop daemon is unavailable.
|
||||||
|
|
||||||
|
## Fixed parser stack
|
||||||
|
|
||||||
|
- Python base: `3.12.10-slim-bookworm`
|
||||||
|
- FastAPI: `0.141.1`
|
||||||
|
- Starlette: `1.6.0`
|
||||||
|
- Pillow: `12.3.0`
|
||||||
|
- pypdf: `6.16.2`
|
||||||
|
- python-multipart: `0.0.32`
|
||||||
|
- Reproducible Linux CPU dependency set: `tools/summarizer/requirements-linux.lock`
|
||||||
|
|
||||||
|
The lock was generated for Python 3.12 on `x86_64-unknown-linux-gnu` with the PyTorch CPU index and contains hashes for every resolved package. A clean local environment installed the generated set with `--require-hashes` and imported the upgraded parser packages successfully.
|
||||||
|
|
||||||
|
## Advisory review
|
||||||
|
|
||||||
|
`pip-audit -r requirements.txt` reports no advisory for FastAPI, Starlette, Pillow, pypdf, PyMuPDF, python-docx, pytesseract or python-multipart. It reports 45 advisories across `torch==2.6.0` and `transformers==4.48.3`.
|
||||||
|
|
||||||
|
Those remaining findings belong to the model-loading stack rather than the CV file decoder paths remediated by SEC-006. They remain open under JT-017; this record does not accept or close them.
|
||||||
|
|
||||||
|
## Regression proof
|
||||||
|
|
||||||
|
- Python parser suite: 32/32 passed.
|
||||||
|
- Focused CV extraction/backend suite: 51/51 passed.
|
||||||
|
- Full backend suite: 719/719 passed.
|
||||||
|
- Generated boundary cases cover mismatched signatures, invalid text bytes, excessive PDF pages, excessive image pixels, excessive DOCX entries and sanitized unexpected parser failures.
|
||||||
|
- The API no longer performs local PDF, DOCX or image parsing when the sidecar returns no text. A bounded UTF-8 text/Markdown fallback remains.
|
||||||
|
|
||||||
|
## Remaining verification
|
||||||
|
|
||||||
|
`docker info` fails because `dockerDesktopLinuxEngine` is not running. When a daemon is available:
|
||||||
|
|
||||||
|
1. Build `tools/summarizer/Dockerfile` without changing the lock.
|
||||||
|
2. Start the image with the intended SEC-007 runtime restrictions.
|
||||||
|
3. Run health plus benign TXT, Markdown, PDF, DOCX and image extraction smoke tests.
|
||||||
|
4. Record the image digest and measured memory/CPU use before any production activation.
|
||||||
@@ -44,7 +44,7 @@ Updated: 2026-08-30
|
|||||||
|
|
||||||
### In progress
|
### In progress
|
||||||
|
|
||||||
- SEC-006 parser dependency remediation is unblocked now that package-index access has been explicitly authorized. Resolve a compatible fixed parser stack, audit it, and rerun the benign extraction corpus before starting SEC-007 isolation.
|
- SEC-007 bounded document processing. Upload-facing parser dependencies are fixed and hash-locked; the sidecar now rejects signature mismatches, decompression/page/pixel/output excesses, the backend no longer retries PDF/DOCX/image parsing in-process, and unexpected parser details are sanitized. Child-process deadline/resource isolation and container controls remain.
|
||||||
|
|
||||||
### Remaining
|
### Remaining
|
||||||
|
|
||||||
@@ -108,17 +108,17 @@ Updated: 2026-08-30
|
|||||||
- Playwright: 8/10 passed on the first complete run; both failures were ambiguous selectors in the newly responsive Career selector, not product failures. Both corrected focused regressions now pass (2/2); final full rerun remains in the end-of-batch gate.
|
- Playwright: 8/10 passed on the first complete run; both failures were ambiguous selectors in the newly responsive Career selector, not product failures. Both corrected focused regressions now pass (2/2); final full rerun remains in the end-of-batch gate.
|
||||||
- Manual desktop browser review: webpack development server rendered the new Career navigation and Overview correctly in dark mode; API-dependent profile status remained unavailable because the backend was not running for that isolated UI review.
|
- Manual desktop browser review: webpack development server rendered the new Career navigation and Overview correctly in dark mode; API-dependent profile status remained unavailable because the backend was not running for that isolated UI review.
|
||||||
|
|
||||||
- **Overall programme status:** Active but externally blocked. Eight packages are locally verified and twenty-five are implemented with verification incomplete. The prioritized admin-only version indicator, every immediate repository/browser item, SEC-009, the PROD-001 read-only inventory, and the PROD-003 safe benchmark harness are complete on the release branch.
|
- **Overall programme status:** Active but externally blocked. Eight packages are locally verified, twenty-six are implemented with verification incomplete, and SEC-007 is in progress. The prioritized admin-only version indicator, every immediate repository/browser item, SEC-009, the PROD-001 read-only inventory, and the PROD-003 safe benchmark harness are complete on the release branch.
|
||||||
- **Current work package:** SEC-006 parser dependency remediation; package-index access is now authorized.
|
- **Current work package:** SEC-007 bounded isolated document processing; SEC-006 is implemented locally and awaits Docker image smoke.
|
||||||
- **Completed work packages:** None are `DONE`; all repository security/AI packages still have applicable browser, provider and/or production gates.
|
- **Completed work packages:** None are `DONE`; all repository security/AI packages still have applicable browser, provider and/or production gates.
|
||||||
- **Locally verified work:** SEC-001, SEC-002, SEC-003, SEC-005A, CORE-001, PROD-002, DEP-001 and VER-001 (`VERIFIED LOCALLY`).
|
- **Locally verified work:** SEC-001, SEC-002, SEC-003, SEC-005A, CORE-001, PROD-002, DEP-001 and VER-001 (`VERIFIED LOCALLY`).
|
||||||
- **Implemented, verification incomplete:** SEC-004, SEC-005B, SEC-008, SEC-009, CORE-002, BG-001, OPS-001A/B/C, POL-001/002, AI-001/002/003/004, UX-001/002/003, QA-001, CAREER-001/002, MAIL-001, JOBS-001/002 and PRODUCT-001 (`IMPLEMENTED — NOT VERIFIED`). Their safe repository/browser scope is implemented; production/native-device/provider/retention gates remain where recorded.
|
- **Implemented, verification incomplete:** SEC-004, SEC-005B, SEC-006, SEC-008, SEC-009, CORE-002, BG-001, OPS-001A/B/C, POL-001/002, AI-001/002/003/004, UX-001/002/003, QA-001, CAREER-001/002, MAIL-001, JOBS-001/002 and PRODUCT-001 (`IMPLEMENTED — NOT VERIFIED`). Their safe repository/browser scope is implemented; production/native-device/provider/retention gates remain where recorded.
|
||||||
- **Production-verified work:** None.
|
- **Production-verified work:** None.
|
||||||
- **Blocked work:** PROD-001/003/004 and REL-001 require network/backup/model/deployment authority and unfinished dependencies. Real provider and live deletion/restore checks remain gated; DEP-001 awaits approved merge/live verification.
|
- **Blocked work:** PROD-001/003/004 and REL-001 require network/backup/model/deployment authority and unfinished dependencies. Real provider and live deletion/restore checks remain gated; DEP-001 awaits approved merge/live verification.
|
||||||
- **Deferred work:** None. Conditional multi-replica coordination, model deletion, realtime operation delivery and unrelated production changes remain outside current packages.
|
- **Deferred work:** None. Conditional multi-replica coordination, model deletion, realtime operation delivery and unrelated production changes remain outside current packages.
|
||||||
- **Immediate order:** all sixteen immediate repository items are complete locally, including the original UI/release queue plus SEC-009 cache/tombstone safety, worker restart clocks, universal AI accounting, email-token/Stripe lifecycle tests, exhaustive Job email selectors, the repaired migration chain, CV/public-edge hardening and measured admin/mail scaling. PROD-001 read-only evidence and the PROD-003 plan-only harness are also complete. The final audit is checking tooling/documentation before declaring only external blockers remain.
|
- **Immediate order:** all sixteen immediate repository items are complete locally, including the original UI/release queue plus SEC-009 cache/tombstone safety, worker restart clocks, universal AI accounting, email-token/Stripe lifecycle tests, exhaustive Job email selectors, the repaired migration chain, CV/public-edge hardening and measured admin/mail scaling. PROD-001 read-only evidence and the PROD-003 plan-only harness are also complete. The final audit is checking tooling/documentation before declaring only external blockers remain.
|
||||||
- **Status counts:** 8 `VERIFIED LOCALLY`; 25 `IMPLEMENTED — NOT VERIFIED`; 0 `IN PROGRESS`; 1 `NOT STARTED`; 5 `BLOCKED`; 0 `DONE`; 0 `DEFERRED`.
|
- **Status counts:** 8 `VERIFIED LOCALLY`; 26 `IMPLEMENTED — NOT VERIFIED`; 1 `IN PROGRESS`; 0 `NOT STARTED`; 4 `BLOCKED`; 0 `DONE`; 0 `DEFERRED`.
|
||||||
- **Test status:** backend 683/683; frontend 58/58 suites and 239/239 tests; AI sidecar 23/23; Ollama benchmark harness 5/5 plus safe dry-run; optimized production build/TypeScript; EF model parity; SQLite/MariaDB migration scripts; blank/idempotent/populated SQLite migration-chain tests; disposable fresh/restarted MariaDB 11.8 application startup; Docker Compose config; safe-failure deployment preflight; and Playwright 9/9. npm audit 0 evidence remains current because the lockfile did not change. Jest's slow/open-handle behavior remains recorded.
|
- **Test status:** backend 719/719; frontend 64/64 suites and 272/272 tests; ESLint zero findings; AI sidecar 32/32; Ollama benchmark harness 5/5 plus safe dry-run; optimized production build/TypeScript; EF model parity; SQLite/MariaDB migration scripts; blank/idempotent/populated SQLite migration-chain tests; disposable fresh/restarted MariaDB 11.8 application startup; Docker Compose config; safe-failure deployment preflight; and Playwright 9/9. npm audit remains at zero. The parser image build is pending because Docker Desktop's daemon is offline. Jest's slow/open-handle behavior and six Python deprecation warnings remain recorded.
|
||||||
- **Deployment status:** Gitea pull-request run 609 passed the complete CI job in 4m20s. Deploy was intentionally skipped because the workflow deploys only a `push` to `main`; live remains unchanged. No merge/deployment was performed directly, no production migrations were run and the AI operation worker remains disabled by default.
|
- **Deployment status:** Gitea pull-request run 609 passed the complete CI job in 4m20s. Deploy was intentionally skipped because the workflow deploys only a `push` to `main`; live remains unchanged. No merge/deployment was performed directly, no production migrations were run and the AI operation worker remains disabled by default.
|
||||||
- **Production status:** State unchanged. Sanitized read-only SSH inventory was performed; no logs, prompts, private rows/content or secret values were read, and no provider/model call, model pull, service restart, file/config change, backup, restore, migration or deployment occurred. It confirmed all-interface Ollama/frontend listeners and stale database-only backups as rollout blockers.
|
- **Production status:** State unchanged. Sanitized read-only SSH inventory was performed; no logs, prompts, private rows/content or secret values were read, and no provider/model call, model pull, service restart, file/config change, backup, restore, migration or deployment occurred. It confirmed all-interface Ollama/frontend listeners and stale database-only backups as rollout blockers.
|
||||||
- **Known regressions:** None found by automated/local browser checks. Jest still needs `--forceExit` and reports its existing open-handle notice. Email-provider/send tests are fake/local only; real delivery is not claimed. Current MAIL browser evidence is 1280×720 only because the browser surface could not resize or perform native Tab traversal. Interrupted attempts are aged after 15 minutes and notified without retry; the five-minute scan is unmeasured on a large ledger. The direct clean EF-only SQLite defect and synchronous AI accounting gap are closed; migration/reconciler dual ownership remains architectural debt.
|
- **Known regressions:** None found by automated/local browser checks. Jest still needs `--forceExit` and reports its existing open-handle notice. Email-provider/send tests are fake/local only; real delivery is not claimed. Current MAIL browser evidence is 1280×720 only because the browser surface could not resize or perform native Tab traversal. Interrupted attempts are aged after 15 minutes and notified without retry; the five-minute scan is unmeasured on a large ledger. The direct clean EF-only SQLite defect and synchronous AI accounting gap are closed; migration/reconciler dual ownership remains architectural debt.
|
||||||
|
|||||||
@@ -222,11 +222,11 @@ This queue records the highest-value work that can proceed without production cr
|
|||||||
- **Required tests:** dependency resolution/audit, Python suite, generated benign corpus.
|
- **Required tests:** dependency resolution/audit, Python suite, generated benign corpus.
|
||||||
- **Required browser verification:** none for dependency-only package.
|
- **Required browser verification:** none for dependency-only package.
|
||||||
- **Required production verification:** image digest and smoke before activation.
|
- **Required production verification:** image digest and smoke before activation.
|
||||||
- **Status:** `BLOCKED`.
|
- **Status:** `IMPLEMENTED — NOT VERIFIED`.
|
||||||
- **Blocker:** repository instructions prohibit internet/package resolution without explicit permission; fixed-version compatibility cannot be resolved or verified offline.
|
- **Blocker:** Docker Desktop is installed but its Linux daemon is offline, so the hash-locked production image has not been built locally.
|
||||||
- **Evidence:** audit lists reachable Pillow/pypdf/multipart/Starlette advisories and compatibility conflict.
|
- **Evidence:** `docs/verification/sec-006-parser-dependencies.md`; upload-facing dependency audit is clear; clean hash install succeeds; parser suite 32/32; backend 719/719.
|
||||||
- **Commit:** none.
|
- **Commit:** none.
|
||||||
- **Remaining work:** explicit package-index permission, compatible fixed-version resolution, lock/hash refresh, audit, benign corpus parity and image smoke; do not execute malicious files.
|
- **Remaining work:** build the production image from `requirements-linux.lock` and run its health/benign extraction smoke when a Docker daemon is available. Torch/Transformers advisories remain separately tracked under JT-017 and are not accepted as upload-parser findings.
|
||||||
|
|
||||||
### SEC-007 — Bounded isolated document processing
|
### SEC-007 — Bounded isolated document processing
|
||||||
|
|
||||||
@@ -240,11 +240,11 @@ This queue records the highest-value work that can proceed without production cr
|
|||||||
- **Required tests:** generated boundary/corrupt fixtures, harmless sleeping child, cancellation/restart cleanup, outage/no-fallback, container assertions.
|
- **Required tests:** generated boundary/corrupt fixtures, harmless sleeping child, cancellation/restart cleanup, outage/no-fallback, container assertions.
|
||||||
- **Required browser verification:** synthetic CV upload status/failure; authorized private CV local-only only after safeguards.
|
- **Required browser verification:** synthetic CV upload status/failure; authorized private CV local-only only after safeguards.
|
||||||
- **Required production verification:** measured memory/CPU limits and canary synthetic extraction.
|
- **Required production verification:** measured memory/CPU limits and canary synthetic extraction.
|
||||||
- **Status:** `NOT STARTED`.
|
- **Status:** `IN PROGRESS`.
|
||||||
- **Blocker:** follows dependency update; production sizing requires access.
|
- **Blocker:** production sizing requires access; local Docker container assertions require a running daemon.
|
||||||
- **Evidence:** audit parser call path and limits design.
|
- **Evidence:** signature/container validation, page/pixel/decompression/output ceilings and stable parser errors have boundary tests; backend binary fallback is removed and raw unexpected failures are sanitized. Focused backend 51/51, full backend 719/719 and parser 32/32 pass.
|
||||||
- **Commit:** none.
|
- **Commit:** none.
|
||||||
- **Remaining work:** split behavioral and container commits if needed.
|
- **Remaining work:** move decode into a deadline-bounded child process/process group, add descendant termination and cleanup tests, bound scheduling/backpressure, then apply and verify non-root/read-only/PID/CPU/memory/tmpfs container controls.
|
||||||
|
|
||||||
### SEC-008 — Recoverable attachment mutations
|
### SEC-008 — Recoverable attachment mutations
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM python:3.11
|
FROM python:3.12.10-slim-bookworm
|
||||||
ENV PIP_NO_CACHE_DIR=1 \
|
ENV PIP_NO_CACHE_DIR=1 \
|
||||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
@@ -8,9 +8,9 @@ WORKDIR /app
|
|||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends tesseract-ocr tesseract-ocr-eng \
|
&& apt-get install -y --no-install-recommends tesseract-ocr tesseract-ocr-eng \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
COPY requirements.txt ./
|
COPY requirements-linux.lock ./
|
||||||
RUN python -m pip install --upgrade pip setuptools wheel \
|
RUN python -m pip install --upgrade pip setuptools wheel \
|
||||||
&& python -m pip install --extra-index-url https://download.pytorch.org/whl/cpu -r requirements.txt
|
&& python -m pip install --require-hashes --extra-index-url https://download.pytorch.org/whl/cpu -r requirements-linux.lock
|
||||||
COPY . .
|
COPY . .
|
||||||
EXPOSE 8001
|
EXPOSE 8001
|
||||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8001"]
|
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ Windows:
|
|||||||
```powershell
|
```powershell
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
.\.venv\Scripts\Activate.ps1
|
.\.venv\Scripts\Activate.ps1
|
||||||
pip install -r requirements.txt
|
pip install -r requirements-dev.txt
|
||||||
python -m uvicorn app:app --host 127.0.0.1 --port 8001 --workers 1
|
python -m uvicorn app:app --host 127.0.0.1 --port 8001 --workers 1
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ Linux / macOS:
|
|||||||
```bash
|
```bash
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements-dev.txt
|
||||||
python -m uvicorn app:app --host 127.0.0.1 --port 8001 --workers 1
|
python -m uvicorn app:app --host 127.0.0.1 --port 8001 --workers 1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+101
-10
@@ -17,6 +17,7 @@ import torch
|
|||||||
import pytesseract
|
import pytesseract
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import zipfile
|
||||||
from urllib import request as urllib_request
|
from urllib import request as urllib_request
|
||||||
from urllib.error import URLError, HTTPError
|
from urllib.error import URLError, HTTPError
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
@@ -91,7 +92,17 @@ async def require_service_token(request: Request, call_next):
|
|||||||
MODEL_NAME = "sshleifer/distilbart-cnn-12-6"
|
MODEL_NAME = "sshleifer/distilbart-cnn-12-6"
|
||||||
MAX_INPUT_CHARS = 20000
|
MAX_INPUT_CHARS = 20000
|
||||||
MAX_CONTEXT_CHARS = 2200
|
MAX_CONTEXT_CHARS = 2200
|
||||||
MAX_EXTRACT_FILE_BYTES = 8 * 1024 * 1024
|
MAX_EXTRACT_FILE_BYTES = 5 * 1024 * 1024
|
||||||
|
MAX_EXTRACT_CHARS = 200_000
|
||||||
|
MAX_PDF_PAGES = 40
|
||||||
|
MAX_DOCX_ENTRIES = 256
|
||||||
|
MAX_DOCX_UNCOMPRESSED_BYTES = 32 * 1024 * 1024
|
||||||
|
MAX_DOCX_ENTRY_BYTES = 8 * 1024 * 1024
|
||||||
|
MAX_DOCX_COMPRESSION_RATIO = 100
|
||||||
|
MAX_IMAGE_DIMENSION = 12_000
|
||||||
|
MAX_IMAGE_PIXELS = 40_000_000
|
||||||
|
MAX_PDF_PAGE_PIXELS = 12_000_000
|
||||||
|
MAX_PDF_OCR_PIXELS = 120_000_000
|
||||||
OCR_LANGUAGES = "eng"
|
OCR_LANGUAGES = "eng"
|
||||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"}
|
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"}
|
||||||
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://127.0.0.1:11434").rstrip("/")
|
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://127.0.0.1:11434").rstrip("/")
|
||||||
@@ -1068,36 +1079,110 @@ def _ocr_image(image: Image.Image) -> str:
|
|||||||
return _normalize_text(text)
|
return _normalize_text(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_extracted_size(text: str) -> str:
|
||||||
|
if len(text) > MAX_EXTRACT_CHARS:
|
||||||
|
raise HTTPException(status_code=422, detail="The document contains too much extracted text.")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _check_image_size(image: Image.Image, *, pixel_limit: int | None = None) -> None:
|
||||||
|
pixel_limit = MAX_IMAGE_PIXELS if pixel_limit is None else pixel_limit
|
||||||
|
width, height = image.size
|
||||||
|
if width <= 0 or height <= 0 or width > MAX_IMAGE_DIMENSION or height > MAX_IMAGE_DIMENSION:
|
||||||
|
raise HTTPException(status_code=422, detail="The document image dimensions are not supported.")
|
||||||
|
if width * height > pixel_limit:
|
||||||
|
raise HTTPException(status_code=422, detail="The document image contains too many pixels.")
|
||||||
|
if getattr(image, "n_frames", 1) != 1:
|
||||||
|
raise HTTPException(status_code=422, detail="Multi-frame document images are not supported.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_docx_container(data: bytes) -> None:
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||||
|
entries = archive.infolist()
|
||||||
|
names = {entry.filename for entry in entries}
|
||||||
|
if "[Content_Types].xml" not in names or "word/document.xml" not in names:
|
||||||
|
raise HTTPException(status_code=400, detail="The uploaded file does not match its DOCX extension.")
|
||||||
|
if len(entries) > MAX_DOCX_ENTRIES:
|
||||||
|
raise HTTPException(status_code=422, detail="The DOCX contains too many files.")
|
||||||
|
total_size = 0
|
||||||
|
for entry in entries:
|
||||||
|
total_size += entry.file_size
|
||||||
|
if entry.file_size > MAX_DOCX_ENTRY_BYTES:
|
||||||
|
raise HTTPException(status_code=422, detail="The DOCX contains an oversized file.")
|
||||||
|
if entry.file_size > 0 and entry.compress_size == 0:
|
||||||
|
raise HTTPException(status_code=422, detail="The DOCX compression ratio is not supported.")
|
||||||
|
if entry.compress_size > 0 and entry.file_size / entry.compress_size > MAX_DOCX_COMPRESSION_RATIO:
|
||||||
|
raise HTTPException(status_code=422, detail="The DOCX compression ratio is not supported.")
|
||||||
|
if total_size > MAX_DOCX_UNCOMPRESSED_BYTES:
|
||||||
|
raise HTTPException(status_code=422, detail="The DOCX expands beyond the supported size.")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except (zipfile.BadZipFile, OSError) as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="The uploaded file does not match its DOCX extension.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_file_signature(extension: str, data: bytes) -> None:
|
||||||
|
matches = {
|
||||||
|
".pdf": data.startswith(b"%PDF-"),
|
||||||
|
".docx": data.startswith(b"PK"),
|
||||||
|
".png": data.startswith(b"\x89PNG\r\n\x1a\n"),
|
||||||
|
".jpg": data.startswith(b"\xff\xd8\xff"),
|
||||||
|
".jpeg": data.startswith(b"\xff\xd8\xff"),
|
||||||
|
".webp": len(data) >= 12 and data.startswith(b"RIFF") and data[8:12] == b"WEBP",
|
||||||
|
}
|
||||||
|
if extension in matches and not matches[extension]:
|
||||||
|
raise HTTPException(status_code=400, detail=f"The uploaded file does not match its {extension[1:].upper()} extension.")
|
||||||
|
if extension in {".txt", ".md"} and b"\x00" in data:
|
||||||
|
raise HTTPException(status_code=400, detail="The uploaded text file contains binary data.")
|
||||||
|
|
||||||
|
|
||||||
def _extract_pdf_text(data: bytes) -> tuple[str, bool, int]:
|
def _extract_pdf_text(data: bytes) -> tuple[str, bool, int]:
|
||||||
page_count = 0
|
page_count = 0
|
||||||
extracted_pages = []
|
extracted_pages = []
|
||||||
try:
|
try:
|
||||||
reader = PdfReader(io.BytesIO(data))
|
reader = PdfReader(io.BytesIO(data))
|
||||||
page_count = len(reader.pages)
|
page_count = len(reader.pages)
|
||||||
|
if page_count > MAX_PDF_PAGES:
|
||||||
|
raise HTTPException(status_code=422, detail="The PDF contains too many pages.")
|
||||||
for page in reader.pages:
|
for page in reader.pages:
|
||||||
try:
|
try:
|
||||||
extracted_pages.append(page.extract_text(extraction_mode="layout") or "")
|
extracted_pages.append(page.extract_text(extraction_mode="layout") or "")
|
||||||
except TypeError:
|
except TypeError:
|
||||||
extracted_pages.append(page.extract_text() or "")
|
extracted_pages.append(page.extract_text() or "")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
extracted_pages = []
|
extracted_pages = []
|
||||||
|
|
||||||
text = _normalize_text("\n".join(extracted_pages))
|
text = _check_extracted_size(_normalize_text("\n".join(extracted_pages)))
|
||||||
if len(text) >= 80:
|
if len(text) >= 80:
|
||||||
return text, False, page_count
|
return text, False, page_count
|
||||||
|
|
||||||
doc = fitz.open(stream=data, filetype="pdf")
|
doc = fitz.open(stream=data, filetype="pdf")
|
||||||
page_count = doc.page_count
|
page_count = doc.page_count
|
||||||
|
if page_count > MAX_PDF_PAGES:
|
||||||
|
doc.close()
|
||||||
|
raise HTTPException(status_code=422, detail="The PDF contains too many pages.")
|
||||||
ocr_pages = []
|
ocr_pages = []
|
||||||
|
total_pixels = 0
|
||||||
for page in doc:
|
for page in doc:
|
||||||
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2), alpha=False)
|
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2), alpha=False)
|
||||||
image = Image.open(io.BytesIO(pix.tobytes("png")))
|
page_pixels = pix.width * pix.height
|
||||||
ocr_pages.append(_ocr_image(image))
|
total_pixels += page_pixels
|
||||||
|
if page_pixels > MAX_PDF_PAGE_PIXELS or total_pixels > MAX_PDF_OCR_PIXELS:
|
||||||
doc.close()
|
doc.close()
|
||||||
return _normalize_text("\n".join(ocr_pages)), True, page_count
|
raise HTTPException(status_code=422, detail="The PDF requires too much OCR processing.")
|
||||||
|
image = Image.open(io.BytesIO(pix.tobytes("png")))
|
||||||
|
_check_image_size(image, pixel_limit=MAX_PDF_PAGE_PIXELS)
|
||||||
|
ocr_pages.append(_ocr_image(image))
|
||||||
|
_check_extracted_size("\n".join(ocr_pages))
|
||||||
|
doc.close()
|
||||||
|
return _check_extracted_size(_normalize_text("\n".join(ocr_pages))), True, page_count
|
||||||
|
|
||||||
|
|
||||||
def _extract_docx_text(data: bytes) -> str:
|
def _extract_docx_text(data: bytes) -> str:
|
||||||
|
_validate_docx_container(data)
|
||||||
document = Document(io.BytesIO(data))
|
document = Document(io.BytesIO(data))
|
||||||
parts = []
|
parts = []
|
||||||
blocks = document.iter_inner_content() if hasattr(document, "iter_inner_content") else document.paragraphs
|
blocks = document.iter_inner_content() if hasattr(document, "iter_inner_content") else document.paragraphs
|
||||||
@@ -1113,11 +1198,15 @@ def _extract_docx_text(data: bytes) -> str:
|
|||||||
if "role" in style and parts:
|
if "role" in style and parts:
|
||||||
parts.append("")
|
parts.append("")
|
||||||
parts.append(f"- {text}" if "bullet" in style else text)
|
parts.append(f"- {text}" if "bullet" in style else text)
|
||||||
return _normalize_text("\n".join(parts))
|
return _check_extracted_size(_normalize_text("\n".join(parts)))
|
||||||
|
|
||||||
|
|
||||||
def _extract_plain_text(data: bytes) -> str:
|
def _extract_plain_text(data: bytes) -> str:
|
||||||
return _normalize_text(data.decode("utf-8", errors="ignore"))
|
try:
|
||||||
|
decoded = data.decode("utf-8-sig")
|
||||||
|
except UnicodeDecodeError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="The uploaded text file is not valid UTF-8.") from exc
|
||||||
|
return _check_extracted_size(_normalize_text(decoded))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/extract-text")
|
@app.post("/extract-text")
|
||||||
@@ -1135,6 +1224,9 @@ async def extract_text(file: UploadFile = File(...)):
|
|||||||
raise HTTPException(status_code=400, detail="The uploaded file was empty.")
|
raise HTTPException(status_code=400, detail="The uploaded file was empty.")
|
||||||
if len(data) > MAX_EXTRACT_FILE_BYTES:
|
if len(data) > MAX_EXTRACT_FILE_BYTES:
|
||||||
raise HTTPException(status_code=400, detail="The uploaded file is too large for AI extraction.")
|
raise HTTPException(status_code=400, detail="The uploaded file is too large for AI extraction.")
|
||||||
|
if extension not in {".txt", ".md", ".docx", ".pdf", *IMAGE_EXTENSIONS}:
|
||||||
|
raise HTTPException(status_code=400, detail="This file type is not supported for AI extraction.")
|
||||||
|
_validate_file_signature(extension, data)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if extension in {".txt", ".md"}:
|
if extension in {".txt", ".md"}:
|
||||||
@@ -1149,15 +1241,14 @@ async def extract_text(file: UploadFile = File(...)):
|
|||||||
text, ocr_used, page_count = _extract_pdf_text(data)
|
text, ocr_used, page_count = _extract_pdf_text(data)
|
||||||
elif extension in IMAGE_EXTENSIONS:
|
elif extension in IMAGE_EXTENSIONS:
|
||||||
image = Image.open(io.BytesIO(data))
|
image = Image.open(io.BytesIO(data))
|
||||||
|
_check_image_size(image)
|
||||||
text = _ocr_image(image)
|
text = _ocr_image(image)
|
||||||
ocr_used = True
|
ocr_used = True
|
||||||
page_count = 1
|
page_count = 1
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=400, detail="This file type is not supported for AI extraction.")
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=500, detail=f"AI extraction failed: {exc}") from exc
|
raise HTTPException(status_code=422, detail="Document extraction failed.") from exc
|
||||||
|
|
||||||
if not text:
|
if not text:
|
||||||
raise HTTPException(status_code=422, detail="AI extraction did not find readable text in the uploaded file.")
|
raise HTTPException(status_code=422, detail="AI extraction did not find readable text in the uploaded file.")
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,13 @@
|
|||||||
fastapi==0.115.12
|
fastapi==0.141.1
|
||||||
|
starlette==1.6.0
|
||||||
uvicorn[standard]==0.34.0
|
uvicorn[standard]==0.34.0
|
||||||
transformers==4.48.3
|
transformers==4.48.3
|
||||||
cachetools==5.5.2
|
cachetools==5.5.2
|
||||||
pydantic==2.10.6
|
pydantic==2.10.6
|
||||||
torch==2.6.0
|
torch==2.6.0
|
||||||
pillow==11.1.0
|
pillow==12.3.0
|
||||||
pytesseract==0.3.13
|
pytesseract==0.3.13
|
||||||
pypdf==5.4.0
|
pypdf==6.16.2
|
||||||
pymupdf==1.25.5
|
pymupdf==1.25.5
|
||||||
python-docx==1.1.2
|
python-docx==1.1.2
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.32
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import importlib
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
from PIL import Image
|
||||||
|
from pypdf import PdfWriter
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
@@ -523,6 +526,86 @@ def test_extract_text_rejects_oversized_upload_before_parsing(monkeypatch):
|
|||||||
assert "too large" in response.json()["detail"].lower()
|
assert "too large" in response.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_text_rejects_extension_signature_mismatch(monkeypatch):
|
||||||
|
module = load_app_module(monkeypatch)
|
||||||
|
client = TestClient(module.app)
|
||||||
|
|
||||||
|
response = client.post("/extract-text", files={"file": ("resume.pdf", b"not a pdf", "application/pdf")})
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json()["detail"] == "The uploaded file does not match its PDF extension."
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_text_rejects_binary_plain_text(monkeypatch):
|
||||||
|
module = load_app_module(monkeypatch)
|
||||||
|
client = TestClient(module.app)
|
||||||
|
|
||||||
|
response = client.post("/extract-text", files={"file": ("resume.txt", b"Name\x00binary", "text/plain")})
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json()["detail"] == "The uploaded text file contains binary data."
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_text_rejects_pdf_over_page_limit(monkeypatch):
|
||||||
|
module = load_app_module(monkeypatch)
|
||||||
|
monkeypatch.setattr(module, "MAX_PDF_PAGES", 1)
|
||||||
|
writer = PdfWriter()
|
||||||
|
writer.add_blank_page(width=100, height=100)
|
||||||
|
writer.add_blank_page(width=100, height=100)
|
||||||
|
payload = io.BytesIO()
|
||||||
|
writer.write(payload)
|
||||||
|
client = TestClient(module.app)
|
||||||
|
|
||||||
|
response = client.post("/extract-text", files={"file": ("resume.pdf", payload.getvalue(), "application/pdf")})
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert response.json()["detail"] == "The PDF contains too many pages."
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_text_rejects_image_pixel_limit_before_ocr(monkeypatch):
|
||||||
|
module = load_app_module(monkeypatch)
|
||||||
|
monkeypatch.setattr(module, "MAX_IMAGE_PIXELS", 50)
|
||||||
|
monkeypatch.setattr(module, "_ocr_image", lambda image: (_ for _ in ()).throw(AssertionError("OCR must not run")))
|
||||||
|
payload = io.BytesIO()
|
||||||
|
Image.new("RGB", (10, 10), "white").save(payload, format="PNG")
|
||||||
|
client = TestClient(module.app)
|
||||||
|
|
||||||
|
response = client.post("/extract-text", files={"file": ("resume.png", payload.getvalue(), "image/png")})
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert response.json()["detail"] == "The document image contains too many pixels."
|
||||||
|
|
||||||
|
|
||||||
|
def test_docx_container_rejects_excessive_entries(monkeypatch):
|
||||||
|
module = load_app_module(monkeypatch)
|
||||||
|
monkeypatch.setattr(module, "MAX_DOCX_ENTRIES", 2)
|
||||||
|
payload = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(payload, "w") as archive:
|
||||||
|
archive.writestr("[Content_Types].xml", "types")
|
||||||
|
archive.writestr("word/document.xml", "document")
|
||||||
|
archive.writestr("word/styles.xml", "styles")
|
||||||
|
|
||||||
|
try:
|
||||||
|
module._validate_docx_container(payload.getvalue())
|
||||||
|
except module.HTTPException as exc:
|
||||||
|
assert exc.status_code == 422
|
||||||
|
assert exc.detail == "The DOCX contains too many files."
|
||||||
|
else:
|
||||||
|
raise AssertionError("Expected the DOCX entry limit to reject the container")
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_text_does_not_leak_parser_exception(monkeypatch):
|
||||||
|
module = load_app_module(monkeypatch)
|
||||||
|
monkeypatch.setattr(module, "_extract_pdf_text", lambda data: (_ for _ in ()).throw(RuntimeError("private path C:/secret")))
|
||||||
|
client = TestClient(module.app)
|
||||||
|
|
||||||
|
response = client.post("/extract-text", files={"file": ("resume.pdf", b"%PDF-invalid", "application/pdf")})
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert response.json()["detail"] == "Document extraction failed."
|
||||||
|
assert "secret" not in response.text
|
||||||
|
|
||||||
|
|
||||||
def test_extraction_normalization_preserves_sections_and_bullets(monkeypatch):
|
def test_extraction_normalization_preserves_sections_and_bullets(monkeypatch):
|
||||||
module = load_app_module(monkeypatch)
|
module = load_app_module(monkeypatch)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user