mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 03:33:43 +08:00
fix(artifacts): isolate registered file bytes from caller mutations
This commit is contained in:
parent
35a241a62d
commit
7d26eaf528
@ -155,6 +155,17 @@ public class GeneratedFileCache {
|
||||
@Nullable Long ownerUserId,
|
||||
@Nullable String conversationId) {
|
||||
|
||||
public Entry {
|
||||
// A file id owns its registered bytes, independent of producer buffers.
|
||||
bytes = bytes == null ? null : bytes.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] bytes() {
|
||||
// Download/consumer buffers must not mutate this cached version.
|
||||
return bytes == null ? null : bytes.clone();
|
||||
}
|
||||
|
||||
public boolean expired() {
|
||||
return System.currentTimeMillis() > expireAt;
|
||||
}
|
||||
@ -179,10 +190,10 @@ public class GeneratedFileCache {
|
||||
&& owner.workspaceId().equals(durable.workspaceId())
|
||||
&& owner.conversationId().equals(durable.conversationId())
|
||||
&& Objects.equals(owner.ownerUserId(), durable.ownerUserId())
|
||||
&& Arrays.equals(bytes, durable.bytes())) {
|
||||
&& Arrays.equals(bytes, durable.bytes)) {
|
||||
try {
|
||||
String digest = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(durable.bytes()));
|
||||
sink.artifact(id, digest, durable.bytes().length, durable.mimeType(), Instant.ofEpochMilli(durable.expireAt()));
|
||||
String digest = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(durable.bytes));
|
||||
sink.artifact(id, digest, durable.bytes.length, durable.mimeType(), Instant.ofEpochMilli(durable.expireAt()));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", e);
|
||||
}
|
||||
@ -364,11 +375,11 @@ public class GeneratedFileCache {
|
||||
}
|
||||
|
||||
private void persist(String id, Entry entry) {
|
||||
if (entry.bytes() == null) {
|
||||
if (entry.bytes == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Files.write(storageDir.resolve(id), entry.bytes());
|
||||
Files.write(storageDir.resolve(id), entry.bytes);
|
||||
// expireAt \t mimeType \t base64(filename) \t workspaceId
|
||||
// \t ownerUserId \t base64(conversationId). Base64 keeps unicode and
|
||||
// separators round-trippable without custom escaping.
|
||||
|
||||
@ -73,8 +73,9 @@ public class GeneratedFileController {
|
||||
headers.add(HttpHeaders.CONTENT_DISPOSITION,
|
||||
disposition + "; filename=\"" + sanitizeAscii(entry.filename())
|
||||
+ "\"; filename*=UTF-8''" + encodedName);
|
||||
headers.setContentLength(entry.bytes().length);
|
||||
return ResponseEntity.ok().headers(headers).body(entry.bytes());
|
||||
byte[] content = entry.bytes();
|
||||
headers.setContentLength(content.length);
|
||||
return ResponseEntity.ok().headers(headers).body(content);
|
||||
})
|
||||
.orElseGet(() -> cache.get(id).isPresent()
|
||||
? ResponseEntity.status(403).body(Map.of("error", "Workspace permission denied"))
|
||||
|
||||
@ -5,6 +5,8 @@ import org.junit.jupiter.api.Test;
|
||||
import vip.mate.tool.document.GeneratedFileCache;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
|
||||
@ -51,7 +53,10 @@ class GeneratedFileScrubberTest {
|
||||
GeneratedFileScrubber.AttachmentHit hit = r.attachments().get(0);
|
||||
assertEquals("report.pdf", hit.fileName());
|
||||
assertEquals("file", hit.mediaType());
|
||||
assertSame(bytes, hit.bytes());
|
||||
assertArrayEquals(bytes, hit.bytes());
|
||||
assertNotSame(bytes, hit.bytes(), "attachment owns a copy of the registered version");
|
||||
hit.bytes()[0] = 'X';
|
||||
assertArrayEquals(bytes, cache.get(id).orElseThrow().bytes());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@ -14,6 +14,9 @@ import vip.mate.i18n.I18nService;
|
||||
import vip.mate.tool.builtin.ShellExecuteTool;
|
||||
import vip.mate.tool.document.GeneratedFileCache;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.Executors;
|
||||
@ -89,6 +92,19 @@ class TrustedExecutionObservationTest {
|
||||
assertTrue(missing.observations().isEmpty());
|
||||
}
|
||||
|
||||
@Test void snapshotDigestKeepsMatchingHotCacheAfterCallerMutations() throws Exception {
|
||||
var sink = new ExecutionObservationSink(false);
|
||||
var cache = new GeneratedFileCache(root.resolve("cache"));
|
||||
byte[] input = "registered report".getBytes(StandardCharsets.UTF_8);
|
||||
String id = cache.put(input, "report.txt", "text/plain", context(sink));
|
||||
String recordedDigest = sink.observations().getFirst().artifactDigest();
|
||||
input[0] = 'X';
|
||||
cache.get(id).orElseThrow().bytes()[1] = 'Y';
|
||||
byte[] downloaded = cache.get(id).orElseThrow().bytes();
|
||||
assertEquals(recordedDigest, HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(downloaded)));
|
||||
assertArrayEquals(new GeneratedFileCache(root.resolve("cache")).get(id).orElseThrow().bytes(), downloaded);
|
||||
}
|
||||
|
||||
private ShellExecuteTool shell() {
|
||||
return new ShellExecuteTool(mock(I18nService.class), new GeneratedFileCache(root.resolve("cache")));
|
||||
}
|
||||
|
||||
@ -18,6 +18,28 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
*/
|
||||
class GeneratedFileCachePersistenceTest {
|
||||
|
||||
@Test
|
||||
void callerCannotChangeRegisteredVersionThroughInputBytes(@TempDir Path dir) {
|
||||
var cache = new GeneratedFileCache(dir);
|
||||
byte[] input = "report-v1".getBytes(StandardCharsets.UTF_8);
|
||||
String id = cache.put(input, "report.txt", "text/plain");
|
||||
input[0] = 'X';
|
||||
byte[] persisted = new GeneratedFileCache(dir).get(id).orElseThrow().bytes();
|
||||
assertArrayEquals("report-v1".getBytes(StandardCharsets.UTF_8), persisted);
|
||||
assertArrayEquals(persisted, cache.get(id).orElseThrow().bytes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void callerCannotChangeRegisteredVersionThroughReturnedBytes(@TempDir Path dir) {
|
||||
var cache = new GeneratedFileCache(dir);
|
||||
String id = cache.put("report-v1".getBytes(StandardCharsets.UTF_8), "report.txt", "text/plain");
|
||||
var returned = cache.get(id).orElseThrow();
|
||||
returned.bytes()[0] = 'X';
|
||||
byte[] persisted = new GeneratedFileCache(dir).get(id).orElseThrow().bytes();
|
||||
assertArrayEquals(persisted, cache.get(id).orElseThrow().bytes());
|
||||
assertArrayEquals(persisted, returned.bytes());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a link survives a 'restart' — a fresh cache over the same dir still serves it")
|
||||
void survivesRestart(@TempDir Path dir) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user