feat(education): add production file scanning

This commit is contained in:
2026-07-31 22:42:20 +08:00
parent c8a221acbc
commit 453193e857
20 changed files with 371 additions and 13 deletions

View File

@@ -25,15 +25,20 @@ class FileApiImplTest {
assertEquals("questions.csv", descriptor.name());
assertEquals("text/csv", descriptor.contentType());
assertEquals(3, descriptor.size());
assertEquals("039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81",
descriptor.checksumSha256());
verify(fileService).createFile(new byte[]{1, 2, 3}, "questions.csv", "education/imports", "text/csv");
}
@Test
void scannerUnavailableNeverReportsClean() {
FileApiImpl api = new FileApiImpl(mock(FileService.class));
void scannerDelegatesToFileService() {
FileService fileService = mock(FileService.class);
FileDescriptor descriptor = new FileDescriptor("opaque", "questions.csv", "text/csv", 3);
when(fileService.scan(descriptor)).thenReturn(FileScanStatus.CLEAN);
FileApiImpl api = new FileApiImpl(fileService);
assertEquals(FileScanStatus.UNAVAILABLE,
api.scan(new FileDescriptor("opaque", "questions.csv", "text/csv", 3)));
assertEquals(FileScanStatus.CLEAN, api.scan(descriptor));
verify(fileService).scan(descriptor);
}
@Test

View File

@@ -0,0 +1,69 @@
package cn.iocoder.yudao.module.infra.framework.file.core.scan;
import cn.iocoder.yudao.module.infra.api.file.FileScanStatus;
import cn.iocoder.yudao.module.infra.framework.file.config.FileScanProperties;
import org.junit.jupiter.api.Test;
import java.io.DataInputStream;
import java.net.ServerSocket;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CompletableFuture;
import static org.junit.jupiter.api.Assertions.*;
class ClamAvFileScannerTest {
@Test
void sendsClamdInstreamProtocol() throws Exception {
try (ServerSocket server = new ServerSocket(0)) {
CompletableFuture<byte[]> received = CompletableFuture.supplyAsync(() -> {
try (var socket = server.accept()) {
DataInputStream input = new DataInputStream(socket.getInputStream());
assertArrayEquals("zINSTREAM\0".getBytes(StandardCharsets.US_ASCII), input.readNBytes(10));
int length = input.readInt();
byte[] content = input.readNBytes(length);
assertEquals(0, input.readInt());
socket.getOutputStream().write("stream: OK\0".getBytes(StandardCharsets.US_ASCII));
return content;
} catch (Exception ex) {
throw new RuntimeException(ex);
}
});
FileScanProperties properties = properties(server.getLocalPort());
assertEquals(FileScanStatus.CLEAN, new ClamAvFileScanner(properties).scan(new byte[]{1, 2, 3}));
assertArrayEquals(new byte[]{1, 2, 3}, received.get());
}
}
@Test
void mapsFoundErrorAndDisabledFailClosed() throws Exception {
assertEquals(FileScanStatus.UNAVAILABLE, new ClamAvFileScanner(new FileScanProperties()).scan(new byte[]{1}));
assertEquals(FileScanStatus.INFECTED, scanResponse("stream: Eicar-Signature FOUND\0"));
assertEquals(FileScanStatus.ERROR, scanResponse("stream: size limit exceeded ERROR\0"));
}
private FileScanStatus scanResponse(String response) throws Exception {
try (ServerSocket server = new ServerSocket(0)) {
CompletableFuture.runAsync(() -> {
try (var socket = server.accept()) {
DataInputStream input = new DataInputStream(socket.getInputStream());
input.readNBytes(10);
for (int length; (length = input.readInt()) != 0;) input.readNBytes(length);
socket.getOutputStream().write(response.getBytes(StandardCharsets.US_ASCII));
} catch (Exception ex) {
throw new RuntimeException(ex);
}
});
return new ClamAvFileScanner(properties(server.getLocalPort())).scan(new byte[]{1});
}
}
private FileScanProperties properties(int port) {
FileScanProperties properties = new FileScanProperties();
properties.setEnabled(true);
properties.setHost("127.0.0.1");
properties.setPort(port);
return properties;
}
}

View File

@@ -5,11 +5,14 @@ import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.ObjectUtils;
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
import cn.iocoder.yudao.framework.test.core.util.AssertUtils;
import cn.iocoder.yudao.module.infra.api.file.FileDescriptor;
import cn.iocoder.yudao.module.infra.api.file.FileScanStatus;
import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FileCreateReqVO;
import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePageReqVO;
import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO;
import cn.iocoder.yudao.module.infra.dal.mysql.file.FileMapper;
import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClient;
import cn.iocoder.yudao.module.infra.framework.file.core.scan.ClamAvFileScanner;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -40,6 +43,9 @@ public class FileServiceImplTest extends BaseDbUnitTest {
@MockitoBean
private FileConfigService fileConfigService;
@MockitoBean
private ClamAvFileScanner fileScanner;
@BeforeEach
public void setUp() {
FileServiceImpl.PATH_PREFIX_DATE_ENABLE = true;
@@ -146,6 +152,43 @@ public class FileServiceImplTest extends BaseDbUnitTest {
assertEquals(content.length, file.getSize());
}
@Test
public void scanUsesOnlyRegisteredFileClientAndValidatesIntegrity() throws Exception {
byte[] content = {1, 2, 3};
FileDO file = new FileDO().setConfigId(10L).setPath("safe/file.csv")
.setUrl("https://storage.example/file.csv").setSize(3L);
fileMapper.insert(file);
FileClient client = mock(FileClient.class);
when(fileConfigService.getFileClient(10L)).thenReturn(client);
when(client.getContent("safe/file.csv")).thenReturn(content);
when(fileScanner.scan(content)).thenReturn(FileScanStatus.CLEAN);
FileScanStatus result = fileService.scan(new FileDescriptor(file.getUrl(), "file.csv", "text/csv", 3,
"039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81"));
assertEquals(FileScanStatus.CLEAN, result);
verify(client).getContent("safe/file.csv");
verify(fileScanner).scan(content);
}
@Test
public void scanRejectsArbitraryUrlAndIntegrityMismatch() throws Exception {
assertEquals(FileScanStatus.ERROR, fileService.scan(
new FileDescriptor("http://127.0.0.1:8080/admin", "x", null, 1)));
verifyNoInteractions(fileConfigService, fileScanner);
FileDO file = new FileDO().setConfigId(10L).setPath("safe/file.csv")
.setUrl("https://storage.example/file.csv").setSize(3L);
fileMapper.insert(file);
FileClient client = mock(FileClient.class);
when(fileConfigService.getFileClient(10L)).thenReturn(client);
when(client.getContent("safe/file.csv")).thenReturn(new byte[]{1, 2});
assertEquals(FileScanStatus.ERROR, fileService.scan(
new FileDescriptor(file.getUrl(), "file.csv", "text/csv", 3)));
verifyNoInteractions(fileScanner);
}
@Test
public void testDeleteFile_success() throws Exception {
// mock 数据