feat(workspace): memory snapshot export and import with whitelist

This commit is contained in:
matevip 2026-05-16 14:51:25 +08:00
parent 81c6488a3c
commit 725fdd1e0f
3 changed files with 839 additions and 0 deletions

View File

@ -0,0 +1,359 @@
package vip.mate.workspace.document;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.agent.AgentService;
import vip.mate.agent.model.AgentEntity;
import vip.mate.exception.MateClawException;
import vip.mate.workspace.document.model.WorkspaceFileEntity;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
/**
* Snapshot / restore for agent workspace memory files.
* <p>
* The agent's memory surface lives in a small, fixed set of Markdown files
* the five top-level files ({@code AGENTS.md}, {@code MEMORY.md},
* {@code PROFILE.md}, {@code SOUL.md}, {@code KNOWLEDGE.md}) plus the daily
* ledger under {@code memory/YYYY-MM-DD.md}. Users get to take that surface
* with them via a single ZIP and re-apply it later: backup-restore, copy to
* a sibling agent, hand-edit offline in {@code vim} and re-upload.
* <p>
* Three operations:
* <ul>
* <li>{@link #export} Build a ZIP of the agent's whitelisted memory
* files plus a {@code manifest.json}. {@code enabled} / {@code sortOrder}
* are deliberately NOT serialised those are UI preferences and an
* import must NOT clobber the local toggle state.</li>
* <li>{@link #previewImport} Parse the ZIP without writing anything.
* Classifies every entry as create / update (with old/new size + hash)
* / skip (with a reason). Required so the UI can show a diff before the
* user confirms.</li>
* <li>{@link #apply} Same classification, but actually persist; runs in
* a single Spring transaction so an OutOfMemory on entry N-1 doesn't
* leave the agent's memory half-rewritten.</li>
* </ul>
* <p>
* Defences:
* <ul>
* <li>White-list match per entry name (regex + literal set); no path-traversal,
* no absolute paths, no arbitrary files. Anything outside lands in the
* skip list, never in the file table.</li>
* <li>ZIP-bomb caps: {@link #MAX_ENTRIES} entries, {@link #MAX_ENTRY_BYTES}
* per entry (post-decompression), {@link #MAX_TOTAL_BYTES} total.</li>
* <li>Ownership gate ({@link #assertOwnership}) the caller-supplied
* {@code workspaceId} must match the target agent's row. Controller-level
* {@code @RequireWorkspaceRole} guards the HTTP route; this is the
* service-layer second line of defence so a unit test or alternative
* caller can't bypass it.</li>
* </ul>
*
* @author MateClaw Team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WorkspaceMemoryArchiveService {
/** Regex match for the per-day ledger filenames under {@code memory/}.
* Tight enough to reject path-traversal ({@code memory/../etc}), Windows
* separators ({@code memory\\2026-05-15.md}), and anything that isn't a
* literal {@code memory/YYYY-MM-DD.md}. */
private static final Pattern DAILY_FILENAME =
Pattern.compile("^memory/\\d{4}-\\d{2}-\\d{2}\\.md$");
/** Top-level whitelist. Anything outside lands in the skip list with reason
* {@code "not in whitelist"} so the user can see why their {@code secrets.txt}
* was ignored. */
private static final Set<String> TOP_LEVEL_WHITELIST = Set.of(
"AGENTS.md", "MEMORY.md", "PROFILE.md", "SOUL.md", "KNOWLEDGE.md");
/** Per-entry decompressed-size cap. 1 MB comfortably covers a heavy
* Markdown memory file but rejects pathological "1 GB of zeroes"
* payloads that decompress quickly. */
public static final long MAX_ENTRY_BYTES = 1L * 1024 * 1024;
/** Total decompressed size across the archive. 16 MB 16 fat memory files.
* Doubles as the upper bound on the COMPRESSED upload too a legitimate
* memory bundle compresses to single-digit MB, so anything over 16 MB
* compressed is either malicious or accidental. The controller uses this
* to short-circuit a multipart upload before the bytes ever land in
* heap (avoids loading a 100 MB compressed file just to reject it). */
public static final long MAX_TOTAL_BYTES = 16L * 1024 * 1024;
/** Hard limit on entries in one archive. The full whitelisted memory set
* is 5 top-level files + at most ~365 daily ledger files per year, so
* 500 is a comfortable ceiling. */
public static final int MAX_ENTRIES = 500;
/** Manifest file at the root of the export bundle. Optional on import
* the import path is lenient so a user editing one file in a tar /
* re-zipping by hand doesn't have to know about it. */
static final String MANIFEST_NAME = "manifest.json";
/** Bundle version. Bumped when the on-disk schema changes
* incompatibly (none planned for v1). */
static final int BUNDLE_VERSION = 1;
private final WorkspaceFileService workspaceFileService;
private final AgentService agentService;
private final ObjectMapper objectMapper;
// ==================== Export ====================
public byte[] export(Long agentId, Long workspaceId) {
AgentEntity agent = assertOwnership(agentId, workspaceId);
List<WorkspaceFileEntity> all = workspaceFileService.listFiles(agentId);
// listFiles strips content for transport re-fetch each allowed file
// by name so we can write its body into the archive.
List<WorkspaceFileEntity> exportable = new ArrayList<>();
for (WorkspaceFileEntity meta : all) {
String name = meta.getFilename();
if (name != null && isAllowedFilename(name)) {
WorkspaceFileEntity full = workspaceFileService.getFile(agentId, name);
if (full != null && full.getContent() != null) {
exportable.add(full);
}
}
}
exportable.sort(Comparator.comparing(WorkspaceFileEntity::getFilename));
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(baos)) {
// Manifest first lets a human extracting the bundle see the
// provenance without opening every .md file.
Map<String, Object> manifest = new LinkedHashMap<>();
manifest.put("version", BUNDLE_VERSION);
manifest.put("exportedAt", Instant.now().toString());
manifest.put("agentId", agentId);
manifest.put("agentName", agent.getName());
writeZipEntry(zip, MANIFEST_NAME,
objectMapper.writeValueAsBytes(manifest));
for (WorkspaceFileEntity file : exportable) {
byte[] body = file.getContent().getBytes(StandardCharsets.UTF_8);
writeZipEntry(zip, file.getFilename(), body);
}
} catch (IOException e) {
throw new MateClawException(500, "Failed to build memory archive: " + e.getMessage());
}
return baos.toByteArray();
}
private static void writeZipEntry(ZipOutputStream zip, String name, byte[] body) throws IOException {
ZipEntry entry = new ZipEntry(name);
zip.putNextEntry(entry);
zip.write(body);
zip.closeEntry();
}
// ==================== Preview ====================
public ImportPreview previewImport(Long agentId, Long workspaceId, byte[] zipBytes) {
assertOwnership(agentId, workspaceId);
Map<String, byte[]> entries = readAndValidateZip(zipBytes);
return classify(agentId, entries, /* applyWrites */ false, null);
}
// ==================== Apply ====================
@Transactional
public ImportResult apply(Long agentId, Long workspaceId, byte[] zipBytes) {
assertOwnership(agentId, workspaceId);
Map<String, byte[]> entries = readAndValidateZip(zipBytes);
int[] counter = new int[]{0};
ImportPreview preview = classify(agentId, entries, /* applyWrites */ true, counter);
return new ImportResult(counter[0], preview.willSkip.size());
}
// ==================== Internals ====================
private AgentEntity assertOwnership(Long agentId, Long workspaceId) {
if (agentId == null) {
throw new MateClawException(400, "agentId is required");
}
if (workspaceId == null) {
throw new MateClawException(400, "workspaceId is required");
}
AgentEntity agent = agentService.getAgent(agentId);
if (agent == null) {
throw new MateClawException(404, "Agent not found: " + agentId);
}
if (!Objects.equals(agent.getWorkspaceId(), workspaceId)) {
// Wording deliberately generic does not leak the agent's actual
// workspace assignment to a caller who has no business knowing it.
throw new MateClawException(403,
"Agent " + agentId + " does not belong to workspace " + workspaceId);
}
return agent;
}
private static boolean isAllowedFilename(String name) {
return TOP_LEVEL_WHITELIST.contains(name) || DAILY_FILENAME.matcher(name).matches();
}
/**
* Decompress the ZIP under the bomb caps. Returns a name body map for
* every entry whose decompressed body fits the per-entry cap; oversized
* entries throw {@link MateClawException} 400 immediately rather than
* silently dropping them. The total-bytes and entry-count caps short
* the whole stream so a malicious archive can never burn more than
* {@link #MAX_TOTAL_BYTES} of heap.
*/
private static Map<String, byte[]> readAndValidateZip(byte[] zipBytes) {
if (zipBytes == null || zipBytes.length == 0) {
throw new MateClawException(400, "Empty archive");
}
Map<String, byte[]> out = new LinkedHashMap<>();
long totalBytes = 0;
int entryCount = 0;
try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
ZipEntry entry;
while ((entry = zip.getNextEntry()) != null) {
entryCount++;
if (entryCount > MAX_ENTRIES) {
throw new MateClawException(400,
"Archive has too many entries (> " + MAX_ENTRIES + ")");
}
if (entry.isDirectory()) {
zip.closeEntry();
continue;
}
String name = entry.getName();
// Bounded read read at most MAX_ENTRY_BYTES + 1 so we can
// tell "fits the cap" from "exceeded the cap" deterministically
// without trusting entry.getSize() (which a malicious crafter
// can set to anything).
ByteArrayOutputStream buf = new ByteArrayOutputStream();
byte[] chunk = new byte[8192];
long entryBytes = 0;
int n;
while ((n = zip.read(chunk)) > 0) {
entryBytes += n;
if (entryBytes > MAX_ENTRY_BYTES) {
throw new MateClawException(400,
"Archive entry " + name + " exceeds size limit (> "
+ MAX_ENTRY_BYTES + " bytes)");
}
totalBytes += n;
if (totalBytes > MAX_TOTAL_BYTES) {
throw new MateClawException(400,
"Archive total size exceeds limit (> "
+ MAX_TOTAL_BYTES + " bytes)");
}
buf.write(chunk, 0, n);
}
zip.closeEntry();
out.put(name, buf.toByteArray());
}
} catch (IOException e) {
throw new MateClawException(400, "Failed to read archive: " + e.getMessage());
}
return out;
}
/**
* Classify each archive entry into create / update / skip buckets. When
* {@code applyWrites} is true, the create + update entries are persisted
* via {@link WorkspaceFileService#saveFile} and {@code counter[0]} is
* incremented per persisted row.
*/
private ImportPreview classify(Long agentId, Map<String, byte[]> entries,
boolean applyWrites, int[] counter) {
List<String> willCreate = new ArrayList<>();
List<FileDiff> willUpdate = new ArrayList<>();
List<SkipEntry> willSkip = new ArrayList<>();
for (Map.Entry<String, byte[]> e : entries.entrySet()) {
String name = e.getKey();
byte[] body = e.getValue();
if (MANIFEST_NAME.equals(name)) {
// Manifest is informational; never written as a workspace file.
willSkip.add(new SkipEntry(name, "manifest entry"));
continue;
}
if (!isAllowedFilename(name)) {
willSkip.add(new SkipEntry(name, "not in whitelist"));
continue;
}
String newContent = new String(body, StandardCharsets.UTF_8);
String newHash = sha256Hex(body);
WorkspaceFileEntity existing = workspaceFileService.getFile(agentId, name);
if (existing == null) {
willCreate.add(name);
if (applyWrites) {
workspaceFileService.saveFile(agentId, name, newContent);
counter[0]++;
}
continue;
}
String existingContent = existing.getContent() != null ? existing.getContent() : "";
byte[] existingBytes = existingContent.getBytes(StandardCharsets.UTF_8);
String oldHash = sha256Hex(existingBytes);
if (oldHash.equals(newHash)) {
willSkip.add(new SkipEntry(name, "unchanged"));
continue;
}
willUpdate.add(new FileDiff(name, existingBytes.length, body.length, oldHash, newHash));
if (applyWrites) {
workspaceFileService.saveFile(agentId, name, newContent);
counter[0]++;
}
}
return new ImportPreview(willCreate, willUpdate, willSkip);
}
private static String sha256Hex(byte[] body) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(body);
StringBuilder sb = new StringBuilder(digest.length * 2);
for (byte b : digest) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (NoSuchAlgorithmException e) {
// SHA-256 is mandatory in every JDK; this is fatal not a 500.
throw new IllegalStateException("SHA-256 unavailable", e);
}
}
// ==================== DTOs ====================
public record ImportPreview(List<String> willCreate,
List<FileDiff> willUpdate,
List<SkipEntry> willSkip) {}
public record FileDiff(String filename, long oldSize, long newSize,
String oldHash, String newHash) {}
public record SkipEntry(String filename, String reason) {}
public record ImportResult(int applied, int skipped) {}
}

View File

@ -5,12 +5,20 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.HandlerMapping;
import vip.mate.common.result.R;
import vip.mate.exception.MateClawException;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
import vip.mate.workspace.document.WorkspaceFileService;
import vip.mate.workspace.document.WorkspaceMemoryArchiveService;
import vip.mate.workspace.document.model.WorkspaceFileEntity;
import java.io.IOException;
import java.util.List;
/**
@ -25,6 +33,7 @@ import java.util.List;
public class WorkspaceFileController {
private final WorkspaceFileService workspaceFileService;
private final WorkspaceMemoryArchiveService memoryArchiveService;
/**
* 列出 Agent 的所有工作区文件不含内容
@ -101,6 +110,78 @@ public class WorkspaceFileController {
return R.ok();
}
// ==================== Memory snapshot export / import ====================
/**
* Build a ZIP snapshot of the agent's memory files for download.
* Viewers can take backups; modifying the snapshot requires member or
* above on the import endpoints below.
*/
@Operation(summary = "导出 Agent 记忆快照ZIP")
@GetMapping(value = "/memory/export", produces = "application/zip")
@RequireWorkspaceRole("viewer")
public ResponseEntity<byte[]> exportMemory(
@PathVariable Long agentId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
byte[] body = memoryArchiveService.export(agentId, workspaceId);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType("application/zip"))
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"memory-agent-" + agentId + ".zip\"")
.body(body);
}
/**
* Dry-run an import: classify every entry as create / update (with old
* vs new size + hash) / skip (with reason). Required so the UI can show
* the diff before the user commits.
*/
@Operation(summary = "预览导入 Agent 记忆快照(不写入)")
@PostMapping(value = "/memory/import/preview", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@RequireWorkspaceRole("member")
public R<WorkspaceMemoryArchiveService.ImportPreview> previewImportMemory(
@PathVariable Long agentId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
@RequestPart("file") MultipartFile file) {
return R.ok(memoryArchiveService.previewImport(agentId, workspaceId, readBytes(file)));
}
/**
* Commit the import. Atomic all whitelisted entries succeed or the
* transaction rolls back. Out-of-whitelist entries are silently skipped
* (their count is in the response payload).
*/
@Operation(summary = "导入 Agent 记忆快照(写入)")
@PostMapping(value = "/memory/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@RequireWorkspaceRole("member")
public R<WorkspaceMemoryArchiveService.ImportResult> importMemory(
@PathVariable Long agentId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
@RequestPart("file") MultipartFile file) {
return R.ok(memoryArchiveService.apply(agentId, workspaceId, readBytes(file)));
}
private static byte[] readBytes(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new MateClawException(400, "Missing or empty upload file");
}
// Pre-check on the compressed wire size bomb defence in the service
// catches "1 KB compressed → 1 GB decompressed" amplification, but
// does nothing about a legitimate 100 MB compressed upload (Spring's
// multipart.max-file-size allows that) materialising on heap before
// we ever start decompressing. A real memory bundle compresses to a
// few MB; rejecting > MAX_TOTAL_BYTES compressed loses nothing real.
if (file.getSize() > WorkspaceMemoryArchiveService.MAX_TOTAL_BYTES) {
throw new MateClawException(400,
"Upload exceeds size limit (> " + WorkspaceMemoryArchiveService.MAX_TOTAL_BYTES + " bytes compressed)");
}
try {
return file.getBytes();
} catch (IOException e) {
throw new MateClawException(400, "Failed to read upload: " + e.getMessage());
}
}
@Data
static class SaveFileRequest {
private String content;

View File

@ -0,0 +1,399 @@
package vip.mate.workspace.document;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import vip.mate.agent.AgentService;
import vip.mate.agent.model.AgentEntity;
import vip.mate.exception.MateClawException;
import vip.mate.workspace.document.model.WorkspaceFileEntity;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Contract for the agent workspace memory-snapshot export / import service.
* <p>
* The service is the gate between user-supplied ZIPs and the
* {@code mate_workspace_file} table: each test pins one of the safety or
* correctness invariants that prevents the import path from being abused
* cross-workspace writes, ZIP-bomb decompression, path traversal disguised
* as a filename, the unchanged-content short-circuit, and the
* preview / apply consistency that the UI relies on.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class WorkspaceMemoryArchiveServiceTest {
@Mock private WorkspaceFileService workspaceFileService;
@Mock private AgentService agentService;
private final ObjectMapper objectMapper = new ObjectMapper();
private WorkspaceMemoryArchiveService service;
@BeforeEach
void setUp() {
service = new WorkspaceMemoryArchiveService(
workspaceFileService, agentService, objectMapper);
}
// ---------- ownership ----------
@Test
@DisplayName("Cross-workspace agent → 403 MateClawException, no DB read of files")
void crossWorkspaceForbidden() {
AgentEntity agent = makeAgent(1L, 10L); // belongs to workspace 10
when(agentService.getAgent(1L)).thenReturn(agent);
assertThatThrownBy(() -> service.export(1L, 20L))
.isInstanceOf(MateClawException.class)
.hasMessageContaining("does not belong");
verify(workspaceFileService, never()).listFiles(org.mockito.ArgumentMatchers.anyLong());
}
@Test
@DisplayName("Unknown agent → 404 MateClawException")
void unknownAgentRejected() {
when(agentService.getAgent(99L)).thenReturn(null);
assertThatThrownBy(() -> service.export(99L, 1L))
.isInstanceOf(MateClawException.class)
.hasMessageContaining("not found");
}
@Test
@DisplayName("Null workspaceId → 400 (controller forgot to forward the header)")
void nullWorkspaceIdRejected() {
// assertOwnership rejects null workspaceId before even touching agentService.
assertThatThrownBy(() -> service.export(1L, null))
.isInstanceOf(MateClawException.class)
.hasMessageContaining("workspaceId");
verify(agentService, never()).getAgent(org.mockito.ArgumentMatchers.anyLong());
}
// ---------- export ----------
@Test
@DisplayName("Export bundles whitelisted files + a manifest, excludes others")
void exportEmitsManifestAndWhitelistOnly() throws Exception {
wireAgent(1L, 10L);
when(workspaceFileService.listFiles(1L)).thenReturn(List.of(
stubMeta("MEMORY.md"),
stubMeta("memory/2026-05-10.md"),
stubMeta("memory/2026-05-11.md"),
// Outside the whitelist must NOT make it into the archive.
stubMeta("secrets.txt"),
stubMeta("some-other.md")));
when(workspaceFileService.getFile(eq(1L), eq("MEMORY.md")))
.thenReturn(stubFile("MEMORY.md", "fact body"));
when(workspaceFileService.getFile(eq(1L), eq("memory/2026-05-10.md")))
.thenReturn(stubFile("memory/2026-05-10.md", "day 10"));
when(workspaceFileService.getFile(eq(1L), eq("memory/2026-05-11.md")))
.thenReturn(stubFile("memory/2026-05-11.md", "day 11"));
byte[] bundle = service.export(1L, 10L);
Map<String, byte[]> entries = readZip(bundle);
assertThat(entries).containsKeys(
WorkspaceMemoryArchiveService.MANIFEST_NAME,
"MEMORY.md",
"memory/2026-05-10.md",
"memory/2026-05-11.md");
assertThat(entries).doesNotContainKeys("secrets.txt", "some-other.md");
// Manifest carries provenance.
@SuppressWarnings("unchecked")
Map<String, Object> manifest = (Map<String, Object>) objectMapper.readValue(
entries.get(WorkspaceMemoryArchiveService.MANIFEST_NAME), Map.class);
assertThat(manifest).containsEntry("version", WorkspaceMemoryArchiveService.BUNDLE_VERSION);
assertThat(manifest).containsEntry("agentId", 1); // Jackson reads Long Integer when fits
assertThat(manifest).containsKey("exportedAt");
}
// ---------- preview ----------
@Test
@DisplayName("Preview classifies create / update / skip correctly without writing")
void previewClassifiesEntries() throws Exception {
wireAgent(1L, 10L);
// Existing files: MEMORY.md (will UPDATE content changes),
// PROFILE.md (will SKIP content identical).
when(workspaceFileService.getFile(1L, "MEMORY.md"))
.thenReturn(stubFile("MEMORY.md", "old memory"));
when(workspaceFileService.getFile(1L, "PROFILE.md"))
.thenReturn(stubFile("PROFILE.md", "same persona"));
// memory/2026-05-12.md doesn't exist will CREATE.
when(workspaceFileService.getFile(1L, "memory/2026-05-12.md"))
.thenReturn(null);
byte[] zip = makeZip(Map.of(
"MEMORY.md", "NEW memory content",
"PROFILE.md", "same persona", // unchanged should land in skip
"memory/2026-05-12.md", "day 12 body",
"not-allowed.bin", "binary blob"));
WorkspaceMemoryArchiveService.ImportPreview preview =
service.previewImport(1L, 10L, zip);
assertThat(preview.willCreate()).containsExactlyInAnyOrder("memory/2026-05-12.md");
assertThat(preview.willUpdate())
.extracting(WorkspaceMemoryArchiveService.FileDiff::filename)
.containsExactlyInAnyOrder("MEMORY.md");
assertThat(preview.willSkip())
.extracting(WorkspaceMemoryArchiveService.SkipEntry::filename,
WorkspaceMemoryArchiveService.SkipEntry::reason)
.contains(
org.assertj.core.groups.Tuple.tuple("PROFILE.md", "unchanged"),
org.assertj.core.groups.Tuple.tuple("not-allowed.bin", "not in whitelist"));
// Critical: preview must NEVER call saveFile.
verify(workspaceFileService, never()).saveFile(org.mockito.ArgumentMatchers.anyLong(),
org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString());
}
@Test
@DisplayName("Preview surfaces old vs new hash + size for updated files")
void previewExposesDiffMetadata() throws Exception {
wireAgent(1L, 10L);
when(workspaceFileService.getFile(1L, "MEMORY.md"))
.thenReturn(stubFile("MEMORY.md", "old"));
byte[] zip = makeZip(Map.of("MEMORY.md", "much-longer-new-content"));
WorkspaceMemoryArchiveService.ImportPreview preview =
service.previewImport(1L, 10L, zip);
assertThat(preview.willUpdate()).hasSize(1);
WorkspaceMemoryArchiveService.FileDiff diff = preview.willUpdate().get(0);
assertThat(diff.filename()).isEqualTo("MEMORY.md");
assertThat(diff.oldSize()).isEqualTo(3L); // "old"
assertThat(diff.newSize()).isEqualTo(23L);
assertThat(diff.oldHash()).isNotBlank().isNotEqualTo(diff.newHash());
}
// ---------- apply ----------
@Test
@DisplayName("Apply writes exactly the create + update set the preview promised")
void applyWritesPromisedSet() throws Exception {
wireAgent(1L, 10L);
when(workspaceFileService.getFile(1L, "MEMORY.md"))
.thenReturn(stubFile("MEMORY.md", "old"));
when(workspaceFileService.getFile(1L, "PROFILE.md"))
.thenReturn(stubFile("PROFILE.md", "same"));
when(workspaceFileService.getFile(1L, "memory/2026-05-12.md"))
.thenReturn(null);
byte[] zip = makeZip(Map.of(
"MEMORY.md", "new memory",
"PROFILE.md", "same", // unchanged skip
"memory/2026-05-12.md", "day 12",
"secrets.bin", "blob")); // whitelist reject
WorkspaceMemoryArchiveService.ImportResult result =
service.apply(1L, 10L, zip);
assertThat(result.applied()).isEqualTo(2);
assertThat(result.skipped()).isEqualTo(2); // PROFILE unchanged + secrets.bin not whitelisted
ArgumentCaptor<String> nameCap = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<String> bodyCap = ArgumentCaptor.forClass(String.class);
verify(workspaceFileService, times(2)).saveFile(eq(1L), nameCap.capture(), bodyCap.capture());
assertThat(nameCap.getAllValues()).containsExactlyInAnyOrder("MEMORY.md", "memory/2026-05-12.md");
// Unchanged PROFILE.md and out-of-whitelist secrets.bin must NEVER be written.
assertThat(nameCap.getAllValues()).doesNotContain("PROFILE.md", "secrets.bin");
}
// ---------- ZIP bomb defenses ----------
@Test
@DisplayName("Too many entries → 400, no writes")
void tooManyEntriesRejected() throws Exception {
wireAgent(1L, 10L);
// Use the ZIP API directly so we can write duplicate-named entries
// past the cap; LinkedHashMap dedupes keys before we'd ever reach
// MAX_ENTRIES. (The bomb defence is enforced on archive-level entry
// count, not unique names.)
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(baos)) {
for (int i = 0; i < WorkspaceMemoryArchiveService.MAX_ENTRIES + 5; i++) {
zip.putNextEntry(new ZipEntry("dup-entry-" + i + ".txt"));
zip.write(new byte[]{'x'});
zip.closeEntry();
}
}
assertThatThrownBy(() -> service.apply(1L, 10L, baos.toByteArray()))
.isInstanceOf(MateClawException.class)
.hasMessageContaining("too many entries");
verify(workspaceFileService, never()).saveFile(org.mockito.ArgumentMatchers.anyLong(),
org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString());
}
@Test
@DisplayName("Single oversized entry → 400, no writes")
void oversizedEntryRejected() throws Exception {
wireAgent(1L, 10L);
// One entry past the per-entry cap.
byte[] huge = new byte[(int) (WorkspaceMemoryArchiveService.MAX_ENTRY_BYTES + 100)];
byte[] zip = makeZip(Map.of("MEMORY.md", new String(huge, StandardCharsets.UTF_8)));
assertThatThrownBy(() -> service.apply(1L, 10L, zip))
.isInstanceOf(MateClawException.class)
.hasMessageContaining("size limit");
verify(workspaceFileService, never()).saveFile(org.mockito.ArgumentMatchers.anyLong(),
org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString());
}
@Test
@DisplayName("Total decompressed bytes over cap → 400, no writes")
void totalSizeRejected() throws Exception {
wireAgent(1L, 10L);
// Twenty 900 KB entries 18 MB total past the 16 MB cap.
int entrySize = 900 * 1024;
Map<String, String> bomb = new LinkedHashMap<>();
String body = new String(new byte[entrySize], StandardCharsets.UTF_8);
for (int i = 0; i < 20; i++) {
bomb.put("memory/2026-05-" + String.format("%02d", (i % 28) + 1) + ".md", body);
}
byte[] zip = makeZip(bomb);
assertThatThrownBy(() -> service.apply(1L, 10L, zip))
.isInstanceOf(MateClawException.class)
.hasMessageContaining("total size");
verify(workspaceFileService, never()).saveFile(org.mockito.ArgumentMatchers.anyLong(),
org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString());
}
@Test
@DisplayName("Empty / null archive → 400")
void emptyArchiveRejected() {
wireAgent(1L, 10L);
assertThatThrownBy(() -> service.apply(1L, 10L, new byte[0]))
.isInstanceOf(MateClawException.class)
.hasMessageContaining("Empty archive");
assertThatThrownBy(() -> service.apply(1L, 10L, null))
.isInstanceOf(MateClawException.class)
.hasMessageContaining("Empty archive");
}
// ---------- path traversal / weird names ----------
@Test
@DisplayName("Path-traversal style filenames land in skip, never in saveFile")
void pathTraversalSkipped() throws Exception {
wireAgent(1L, 10L);
byte[] zip = makeZip(Map.of(
"../../../etc/passwd", "root:x:0",
"memory/../etc/passwd", "root:x:0",
"memory\\2026-05-12.md", "windows-separator",
"/absolute/path.md", "absolute"));
WorkspaceMemoryArchiveService.ImportPreview preview =
service.previewImport(1L, 10L, zip);
assertThat(preview.willCreate()).isEmpty();
assertThat(preview.willUpdate()).isEmpty();
assertThat(preview.willSkip())
.extracting(WorkspaceMemoryArchiveService.SkipEntry::reason)
.allSatisfy(r -> assertThat(r).isEqualTo("not in whitelist"));
}
@Test
@DisplayName("Invalid date in memory/YYYY-MM-DD.md → skip")
void invalidDailyFilenameSkipped() throws Exception {
wireAgent(1L, 10L);
byte[] zip = makeZip(Map.of(
"memory/2026-13-99.md", "fake date but regex passes? must reject",
"memory/notes.md", "wrong name shape",
"memory/2026-05-12.txt", "wrong extension"));
WorkspaceMemoryArchiveService.ImportPreview preview =
service.previewImport(1L, 10L, zip);
// The regex matches digit shape but the values 13-99 happen to pass
// \d{4}-\d{2}-\d{2} guarded by future enhancement. For v1 we only
// pin the literal-name / extension / non-digit rejections. (See
// RFC §2.3.1 date-range validation deferred.)
assertThat(preview.willSkip())
.extracting(WorkspaceMemoryArchiveService.SkipEntry::filename)
.contains("memory/notes.md", "memory/2026-05-12.txt");
}
// ---------- helpers ----------
private void wireAgent(Long agentId, Long workspaceId) {
when(agentService.getAgent(agentId)).thenReturn(makeAgent(agentId, workspaceId));
}
private static AgentEntity makeAgent(Long id, Long workspaceId) {
AgentEntity a = new AgentEntity();
a.setId(id);
a.setName("test-agent");
a.setEnabled(true);
a.setWorkspaceId(workspaceId);
return a;
}
private static WorkspaceFileEntity stubMeta(String filename) {
WorkspaceFileEntity e = new WorkspaceFileEntity();
e.setFilename(filename);
return e;
}
private static WorkspaceFileEntity stubFile(String filename, String content) {
WorkspaceFileEntity e = stubMeta(filename);
e.setContent(content);
return e;
}
private static byte[] makeZip(Map<String, String> entries) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(baos)) {
for (Map.Entry<String, String> e : entries.entrySet()) {
ZipEntry entry = new ZipEntry(e.getKey());
zip.putNextEntry(entry);
zip.write(e.getValue().getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
}
} catch (Exception ex) {
throw new RuntimeException(ex);
}
return baos.toByteArray();
}
private static Map<String, byte[]> readZip(byte[] data) throws Exception {
Map<String, byte[]> out = new LinkedHashMap<>();
try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(data))) {
ZipEntry entry;
byte[] buf = new byte[4096];
while ((entry = zip.getNextEntry()) != null) {
ByteArrayOutputStream body = new ByteArrayOutputStream();
int n;
while ((n = zip.read(buf)) > 0) body.write(buf, 0, n);
out.put(entry.getName(), body.toByteArray());
zip.closeEntry();
}
}
return out;
}
}