Werkzeugkasten: Anhaenge lesen - Word, Excel, Text (#692)
- Core/Documents/DocumentText: text of .docx (paragraphs, tables as rows), .xlsx (sheet by sheet, every cell at its column, stored values not formulas) and plain text/CSV. No DTD, no external references, every part bounded against zip bombs. PDF is named as not readable yet. - Mail: read_message lists attachments; read_attachment returns the text; save_attachment keeps the file in the agent's workspace (never over an existing file, never outside). - FileRW read/read_many return Word and Excel files as text; writing them stays refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
edf3ff19ea
commit
72d855efcf
@@ -0,0 +1,118 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using ClawdDotNet.Core.Documents;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Documents;
|
||||
|
||||
/// <summary>
|
||||
/// #692: The text of what really comes in — a Word order confirmation, a price list as a
|
||||
/// spreadsheet — without running anything in the file and without being unpacked to death.
|
||||
/// </summary>
|
||||
public sealed class DocumentTextTests
|
||||
{
|
||||
private const string W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||
private const string S = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
|
||||
|
||||
public static byte[] Zip(params (string Name, string Content)[] parts)
|
||||
{
|
||||
using var buffer = new MemoryStream();
|
||||
using (var zip = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true))
|
||||
foreach (var (name, content) in parts)
|
||||
{
|
||||
using var stream = zip.CreateEntry(name).Open();
|
||||
stream.Write(Encoding.UTF8.GetBytes(content));
|
||||
}
|
||||
return buffer.ToArray();
|
||||
}
|
||||
|
||||
public static byte[] Docx(string body)
|
||||
=> Zip(("word/document.xml", $"""<?xml version="1.0" encoding="UTF-8"?><w:document xmlns:w="{W}"><w:body>{body}</w:body></w:document>"""));
|
||||
|
||||
[Fact]
|
||||
public void A_word_document_comes_out_as_paragraphs_and_table_rows()
|
||||
{
|
||||
var doc = Docx("""
|
||||
<w:p><w:r><w:t>Order confirmation</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:t xml:space="preserve">No. </w:t></w:r><w:r><w:t>4711</w:t></w:r><w:r><w:tab/><w:t>due 2026-10-15</w:t></w:r></w:p>
|
||||
<w:tbl>
|
||||
<w:tr><w:tc><w:p><w:r><w:t>Item</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>Qty</w:t></w:r></w:p></w:tc></w:tr>
|
||||
<w:tr><w:tc><w:p><w:r><w:t>Paper A4</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>40</w:t></w:r></w:p></w:tc></w:tr>
|
||||
</w:tbl>
|
||||
<w:p><w:r><w:t>Thank you.</w:t></w:r></w:p>
|
||||
""");
|
||||
|
||||
var text = DocumentText.Extract(doc, "confirmation.docx", 10_000);
|
||||
|
||||
text.Ok.ShouldBeTrue(text.Problem);
|
||||
text.Kind.ShouldBe("Word document");
|
||||
text.Text.ShouldContain("Order confirmation");
|
||||
text.Text.ShouldContain("No. 4711\tdue 2026-10-15");
|
||||
text.Text.ShouldContain("| Item | Qty |");
|
||||
text.Text.ShouldContain("| Paper A4 | 40 |");
|
||||
text.Text.ShouldEndWith("Thank you.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_spreadsheet_comes_out_sheet_by_sheet_with_every_cell_at_its_column()
|
||||
{
|
||||
var xlsx = Zip(
|
||||
("xl/workbook.xml", $"""<workbook xmlns="{S}" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Prices" sheetId="1" r:id="rId1"/><sheet name="Notes" sheetId="2" r:id="rId2"/></sheets></workbook>"""),
|
||||
("xl/_rels/workbook.xml.rels", """<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Target="/xl/worksheets/sheet2.xml"/></Relationships>"""),
|
||||
("xl/sharedStrings.xml", $"""<sst xmlns="{S}"><si><t>Item</t></si><si><r><t>Pa</t></r><r><t>per</t></r><rPh><t>ignored</t></rPh></si><si><t>Price</t></si></sst>"""),
|
||||
("xl/worksheets/sheet1.xml", $"""
|
||||
<worksheet xmlns="{S}"><sheetData>
|
||||
<row r="1"><c r="A1" t="s"><v>0</v></c><c r="C1" t="s"><v>2</v></c></row>
|
||||
<row r="2"><c r="A2" t="s"><v>1</v></c><c r="B2" t="b"><v>1</v></c><c r="C2"><f>B2*3</f><v>4.5</v></c></row>
|
||||
</sheetData></worksheet>
|
||||
"""),
|
||||
("xl/worksheets/sheet2.xml", $"""<worksheet xmlns="{S}"><sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>Order by Friday</t></is></c></row></sheetData></worksheet>"""));
|
||||
|
||||
var text = DocumentText.Extract(xlsx, "prices.xlsx", 10_000);
|
||||
|
||||
text.Ok.ShouldBeTrue(text.Problem);
|
||||
text.Text.ShouldContain("## Sheet: Prices\nItem\t\tPrice\nPaper\tTRUE\t4.5".Replace("\n", Environment.NewLine));
|
||||
text.Text.ShouldContain("## Sheet: Notes");
|
||||
text.Text.ShouldContain("Order by Friday");
|
||||
text.Text.ShouldNotContain("ignored", customMessage: "phonetic hints are no content");
|
||||
text.Text.ShouldNotContain("B2*3", customMessage: "the stored value is read, the formula is not");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_document_with_a_dtd_is_refused_not_resolved()
|
||||
{
|
||||
var doc = Zip(("word/document.xml", $"""<?xml version="1.0"?><!DOCTYPE x [<!ENTITY e SYSTEM "file:///etc/passwd">]><w:document xmlns:w="{W}"><w:body><w:p><w:r><w:t>&e;</w:t></w:r></w:p></w:body></w:document>"""));
|
||||
|
||||
var text = DocumentText.Extract(doc, "evil.docx", 10_000);
|
||||
|
||||
text.Ok.ShouldBeFalse();
|
||||
text.Problem!.ShouldContain("could not be read");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_part_that_unpacks_beyond_the_limit_is_stopped()
|
||||
{
|
||||
var huge = new string('a', (int)DocumentText.MaxPartBytes + 1024);
|
||||
var doc = Docx($"<w:p><w:r><w:t>{huge}</w:t></w:r></w:p>");
|
||||
|
||||
DocumentText.Extract(doc, "bomb.docx", 100).Ok.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Text_is_cut_at_the_limit_and_odd_bytes_are_no_error()
|
||||
{
|
||||
var cut = DocumentText.Extract(Encoding.UTF8.GetBytes(new string('x', 500)), "a.csv", 100);
|
||||
cut.Truncated.ShouldBeTrue();
|
||||
cut.Text.Length.ShouldBe(100);
|
||||
|
||||
DocumentText.Extract([0x50, 0x72, 0x65, 0x69, 0x73, 0x20, 0xE4], "latin.txt", 100).Text.ShouldBe("Preis ä");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pdf_and_unknown_files_are_named_not_guessed()
|
||||
{
|
||||
DocumentText.Extract([1, 2, 3], "invoice.pdf", 100).Problem!.ShouldContain("PDF");
|
||||
DocumentText.Extract([1, 2, 3], "image.png", 100).Ok.ShouldBeFalse();
|
||||
DocumentText.Extract([1, 2, 3], "broken.docx", 100).Ok.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.FileRW\ClawdDotNet.Tools.FileRW.csproj" />
|
||||
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.FTP\ClawdDotNet.Tools.FTP.csproj" />
|
||||
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.RocketChat\ClawdDotNet.Tools.RocketChat.csproj" />
|
||||
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.Mail\ClawdDotNet.Tools.Mail.csproj" />
|
||||
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.Memory\ClawdDotNet.Tools.Memory.csproj" />
|
||||
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.SocialMediaManager\ClawdDotNet.Tools.SocialMediaManager.csproj" />
|
||||
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.Taskboard\ClawdDotNet.Tools.Taskboard.csproj" />
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using ClawdDotNet.Tools.FileRW;
|
||||
using ClawdDotNet.Tools.Mail;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Tools.Tests.FileRW;
|
||||
|
||||
/// <summary>#692: A Word file in the workspace is read as its text, and an attachment's name never leaves it.</summary>
|
||||
public sealed class DocumentReadTests : IDisposable
|
||||
{
|
||||
private readonly string _workspace = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"), "Workspace");
|
||||
|
||||
public DocumentReadTests() => Directory.CreateDirectory(_workspace);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(Path.GetDirectoryName(_workspace)!, recursive: true); }
|
||||
catch { /* cleanup is secondary */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_returns_the_text_of_a_word_document()
|
||||
{
|
||||
using (var zip = ZipFile.Open(Path.Combine(_workspace, "order.docx"), ZipArchiveMode.Create))
|
||||
using (var stream = zip.CreateEntry("word/document.xml").Open())
|
||||
stream.Write(Encoding.UTF8.GetBytes(
|
||||
"""<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>40 x Paper A4</w:t></w:r></w:p></w:body></w:document>"""));
|
||||
|
||||
var context = new AgentToolContext("buyer", "inst", new Dictionary<string, object?>(), new NullStateStore(), NullLogger.Instance,
|
||||
CancellationToken.None, WorkspacePath: _workspace);
|
||||
var result = await new FileRWTool().ExecuteAsync(JsonDocument.Parse("""{"action":"read","path":"order.docx"}""").RootElement, context, CancellationToken.None);
|
||||
|
||||
result.Success.ShouldBeTrue(result.ErrorMessage);
|
||||
result.Content.ShouldContain("text of a Word document");
|
||||
result.Content.ShouldContain("40 x Paper A4");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Writing_a_word_document_stays_refused()
|
||||
{
|
||||
var context = new AgentToolContext("buyer", "inst", new Dictionary<string, object?>(), new NullStateStore(), NullLogger.Instance,
|
||||
CancellationToken.None, WorkspacePath: _workspace);
|
||||
var result = await new FileRWTool().ExecuteAsync(
|
||||
JsonDocument.Parse("""{"action":"write","path":"fake.docx","content":"x"}""").RootElement, context, CancellationToken.None);
|
||||
|
||||
result.Success.ShouldBeFalse("only reading a document needs no place on the list");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("../../etc/passwd", "passwd")]
|
||||
[InlineData("..\\..\\Windows\\win.ini", "win.ini")]
|
||||
[InlineData("C:/x/invoice.pdf", "invoice.pdf")]
|
||||
[InlineData(".hidden", "hidden")]
|
||||
[InlineData("", "attachment")]
|
||||
[InlineData("a:b?c.txt", "a_b_c.txt")]
|
||||
public void An_attachment_name_is_a_file_name_and_nothing_more(string given, string expected)
|
||||
=> MailTool.SafeFileName(given).ShouldBe(expected);
|
||||
}
|
||||
Reference in New Issue
Block a user