XuqmGroup-Server/update-service/src/main/java/com/xuqm/update/service/RnBundlePackageService.java

464 行
22 KiB
Java

package com.xuqm.update.service;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.StreamReadFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.update.entity.RnBundleEntity;
import com.xuqm.update.model.RnBundlePackage;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.MessageDigest;
import java.security.DigestInputStream;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.HashMap;
import java.util.Map;
import java.util.Locale;
import java.util.Set;
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
import org.apache.commons.compress.archivers.zip.ZipFile;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
/**
* RN 插件 ZIP 的唯一解析、校验和存储实现。
*
* <p>文件名、表单默认值和历史 manifest.json 均不参与身份推导。只有 ZIP 根目录下唯一的
* rn-manifest.json 是身份源,Bundle 内容与 manifest SHA-256 不一致时拒绝上传。</p>
*/
@Service
public class RnBundlePackageService {
private static final String MANIFEST_NAME = "rn-manifest.json";
private static final long MAX_MANIFEST_BYTES = 256 * 1024;
/** 与 RN CLI 和 Android stager 共享的协议上限,避免服务端签出客户端无法安全解压的包。 */
static final long MAX_ARCHIVE_BYTES = 50L * 1024 * 1024;
static final long MAX_BUNDLE_BYTES = 100L * 1024 * 1024;
static final long MAX_ENTRY_BYTES = 100L * 1024 * 1024;
static final long MAX_TOTAL_UNCOMPRESSED_BYTES = 100L * 1024 * 1024;
static final int MAX_ENTRY_COUNT = 4096;
private static final Set<String> REQUIRED_MANIFEST_FIELDS = Set.of(
"schemaVersion", "packageName", "moduleId", "type", "platform", "version",
"appVersion", "appVersionRange", "builtAgainstNativeBaselineId", "buildId",
"bundleFormat", "bugCollectMode", "minNativeApiLevel", "bundleFile",
"bundleByteLength", "bundleSha256", "assets");
private static final Set<String> OPTIONAL_MANIFEST_FIELDS = Set.of("commonVersionRange");
private static final Set<String> ASSET_FIELDS = Set.of("path", "byteLength", "sha256");
private final ObjectMapper objectMapper;
private final Path uploadRoot;
public RnBundlePackageService(
ObjectMapper objectMapper,
@Value("${update.upload-dir:/tmp/xuqm-update}") String uploadDir) {
this.objectMapper = objectMapper;
this.uploadRoot = Path.of(uploadDir).toAbsolutePath().normalize();
}
public RnBundlePackage inspect(MultipartFile source) throws Exception {
if (source == null || source.isEmpty()) {
throw new IllegalArgumentException("bundle ZIP is required");
}
if (source.getSize() < 1 || source.getSize() > MAX_ARCHIVE_BYTES) {
throw new IllegalArgumentException("bundle ZIP size is invalid");
}
Path temporary = Files.createTempFile("xuqm-rn-upload-", ".xuqm.zip");
boolean success = false;
try {
MessageDigest packageDigest = MessageDigest.getInstance("SHA-256");
try (InputStream input = source.getInputStream();
DigestInputStream digestInput = new DigestInputStream(input, packageDigest)) {
Files.copy(digestInput, temporary, StandardCopyOption.REPLACE_EXISTING);
}
String archiveSha256 = HexFormat.of().formatHex(packageDigest.digest());
RnBundlePackage result = inspectZip(temporary, archiveSha256);
success = true;
return result;
} finally {
if (!success) Files.deleteIfExists(temporary);
}
}
public String persist(String appKey, String bundleId, RnBundlePackage bundle) throws Exception {
requireSegment(appKey, "appKey");
requireSegment(bundleId, "bundleId");
requireSegment(bundle.moduleId(), "moduleId");
Path directory = uploadRoot.resolve("rn")
.resolve(appKey)
.resolve(bundle.platform().name().toLowerCase(Locale.ROOT))
.resolve(bundle.moduleId())
.normalize();
requireInsideUploadRoot(directory);
Files.createDirectories(directory);
Path destination = directory.resolve(bundleId + ".xuqm.zip").normalize();
requireInsideUploadRoot(destination);
Files.move(bundle.temporaryFile(), destination, StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE);
return destination.toString();
}
public Path resolveStoredFile(RnBundleEntity entity) {
if (entity == null || !entity.hasTerminalIdentity()) {
throw new IllegalArgumentException("RN bundle record has no terminal identity");
}
Path path = Path.of(entity.getBundleUrl()).toAbsolutePath().normalize();
requireInsideUploadRoot(path);
return path;
}
public Path verifyStoredFile(RnBundleEntity entity) throws Exception {
Path path = resolveStoredFile(entity);
if (!Files.isRegularFile(path)) {
throw new IllegalArgumentException("RN bundle file does not exist");
}
try (InputStream input = Files.newInputStream(path)) {
if (!sha256(input).equals(entity.getArchiveSha256())) {
throw new IllegalStateException("stored RN package SHA-256 mismatch");
}
}
return path;
}
public void deleteStoredFile(String bundleUrl) {
if (bundleUrl == null || bundleUrl.isBlank()) return;
try {
Path path = Path.of(bundleUrl).toAbsolutePath().normalize();
requireInsideUploadRoot(path);
Files.deleteIfExists(path);
} catch (Exception ignored) {
// 数据库保存异常优先返回,孤儿文件由运维审计任务再次清理。
}
}
public void deleteTemporary(RnBundlePackage bundle) {
if (bundle == null || bundle.temporaryFile() == null) return;
try {
Files.deleteIfExists(bundle.temporaryFile());
} catch (Exception ignored) {
// 上传失败后的临时文件清理由系统临时目录最终兜底,不覆盖原始业务异常。
}
}
private RnBundlePackage inspectZip(Path file, String archiveSha256) throws Exception {
try (ZipFile zip = ZipFile.builder().setPath(file).get()) {
Set<String> entryNames = new HashSet<>();
ZipArchiveEntry manifestEntry = null;
long totalUncompressed = 0;
int entryCount = 0;
Enumeration<ZipArchiveEntry> entries = zip.getEntries();
while (entries.hasMoreElements()) {
ZipArchiveEntry entry = entries.nextElement();
if (++entryCount > MAX_ENTRY_COUNT) {
throw new IllegalArgumentException("ZIP contains too many entries");
}
validateEntryName(entry.getName());
if (entry.isUnixSymlink()) {
throw new IllegalArgumentException("ZIP symbolic links are not allowed");
}
if (entry.isDirectory()) {
throw new IllegalArgumentException("ZIP directory entries are not allowed");
}
long size = entry.getSize();
if (size < 0 || size > MAX_ENTRY_BYTES) {
throw new IllegalArgumentException("ZIP entry size is invalid");
}
totalUncompressed = Math.addExact(totalUncompressed, size);
if (totalUncompressed > MAX_TOTAL_UNCOMPRESSED_BYTES) {
throw new IllegalArgumentException("ZIP uncompressed size is too large");
}
if (!entryNames.add(entry.getName())) {
throw new IllegalArgumentException("duplicate ZIP entry: " + entry.getName());
}
if (MANIFEST_NAME.equals(entry.getName())) manifestEntry = entry;
}
if (manifestEntry == null || manifestEntry.isDirectory()) {
throw new IllegalArgumentException("ZIP root rn-manifest.json is required");
}
if (manifestEntry.getSize() < 0 || manifestEntry.getSize() > MAX_MANIFEST_BYTES) {
throw new IllegalArgumentException("rn-manifest.json size is invalid");
}
JsonNode manifest;
try (InputStream input = zip.getInputStream(manifestEntry)) {
JsonParser parser = objectMapper.getFactory().createParser(input);
parser.enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION.mappedFeature());
manifest = objectMapper.readTree(parser);
}
if (manifest == null || !manifest.isObject()) {
throw new IllegalArgumentException("rn-manifest.json must be a JSON object");
}
validateManifestFields(manifest);
int schemaVersion = requiredPositiveInt(manifest, "schemaVersion");
if (schemaVersion != 1) {
throw new IllegalArgumentException("unsupported rn-manifest schemaVersion: " + schemaVersion);
}
String moduleId = requiredText(manifest, "moduleId");
if (!moduleId.matches("[a-z][a-z0-9-]*")) {
throw new IllegalArgumentException("manifest moduleId is invalid");
}
RnBundleEntity.ModuleType type = parseType(requiredText(manifest, "type"));
RnBundleEntity.Platform platform = parsePlatform(requiredText(manifest, "platform"));
String version = requiredText(manifest, "version");
RnVersionContract.parse(version);
String appVersion = requiredText(manifest, "appVersion");
RnVersionContract.parse(appVersion);
String appVersionRange = requiredText(manifest, "appVersionRange");
RnVersionContract.validateRange(appVersionRange);
if (!RnVersionContract.satisfies(appVersion, appVersionRange)) {
throw new IllegalArgumentException("manifest appVersion is outside appVersionRange");
}
String nativeBaseline = requiredText(manifest, "builtAgainstNativeBaselineId");
String buildId = requiredText(manifest, "buildId");
if ("development".equalsIgnoreCase(buildId)) {
throw new IllegalArgumentException("release buildId must not be development");
}
String bundleFormat = requiredText(manifest, "bundleFormat");
if (!Set.of("javascript", "hermes-bytecode").contains(bundleFormat)) {
throw new IllegalArgumentException("manifest bundleFormat is unsupported");
}
String bugCollectMode = requiredText(manifest, "bugCollectMode");
if (!Set.of("platform", "disabled").contains(bugCollectMode)) {
throw new IllegalArgumentException("manifest bugCollectMode is unsupported");
}
String commonRange = optionalText(manifest, "commonVersionRange");
if (type == RnBundleEntity.ModuleType.COMMON) {
if (commonRange != null) {
throw new IllegalArgumentException("common module must not declare commonVersionRange");
}
} else {
if (commonRange == null) {
throw new IllegalArgumentException("app/buz module requires commonVersionRange");
}
RnVersionContract.validateRange(commonRange);
}
int minNativeApiLevel = requiredPositiveInt(manifest, "minNativeApiLevel");
String packageName = requiredText(manifest, "packageName");
String bundleFile = requiredText(manifest, "bundleFile");
String expectedBundleName = moduleId + "." + platform.name().toLowerCase(Locale.ROOT) + ".bundle";
if (!expectedBundleName.equals(bundleFile)) {
throw new IllegalArgumentException("manifest bundleFile does not match module identity");
}
String expectedBundleSha = requiredSha256(manifest, "bundleSha256");
long declaredBundleBytes = requiredNonNegativeLong(manifest, "bundleByteLength");
if (declaredBundleBytes > MAX_BUNDLE_BYTES) {
throw new IllegalArgumentException("manifest bundleByteLength is too large");
}
ZipArchiveEntry bundleEntry = zip.getEntry(bundleFile);
if (bundleEntry == null || bundleEntry.isDirectory()) {
throw new IllegalArgumentException("manifest bundleFile is missing from ZIP");
}
if (bundleEntry.getSize() < 0 || bundleEntry.getSize() > MAX_BUNDLE_BYTES) {
throw new IllegalArgumentException("bundle file size is invalid");
}
DigestResult bundleDigest;
try (InputStream input = zip.getInputStream(bundleEntry)) {
bundleDigest = sha256(input, MAX_BUNDLE_BYTES);
}
if (bundleDigest.byteLength() != declaredBundleBytes
|| bundleDigest.byteLength() != bundleEntry.getSize()) {
throw new IllegalArgumentException("manifest bundleByteLength does not match ZIP content");
}
if (!bundleDigest.sha256().equals(expectedBundleSha)) {
throw new IllegalArgumentException("manifest bundleSha256 does not match ZIP content");
}
validateAssets(zip, manifest.get("assets"), entryNames, bundleFile, bundleDigest.byteLength());
return new RnBundlePackage(
file,
archiveSha256,
packageName,
moduleId,
type,
platform,
version,
appVersionRange,
nativeBaseline,
buildId,
bundleFormat,
commonRange,
minNativeApiLevel,
bundleDigest.sha256());
}
}
private static void validateEntryName(String name) {
if (name == null || name.isBlank() || name.startsWith("/") || name.contains("\\")
|| name.matches("^[A-Za-z]:.*")) {
throw new IllegalArgumentException("unsafe ZIP entry name");
}
for (String segment : name.split("/", -1)) {
if (segment.isBlank() || ".".equals(segment) || "..".equals(segment)) {
throw new IllegalArgumentException("unsafe ZIP entry name");
}
}
}
private void validateManifestFields(JsonNode manifest) {
Set<String> actual = new HashSet<>();
manifest.fieldNames().forEachRemaining(actual::add);
Set<String> allowed = new HashSet<>(REQUIRED_MANIFEST_FIELDS);
allowed.addAll(OPTIONAL_MANIFEST_FIELDS);
if (!actual.containsAll(REQUIRED_MANIFEST_FIELDS) || !allowed.containsAll(actual)) {
throw new IllegalArgumentException("rn-manifest.json fields are invalid");
}
}
private void validateAssets(
ZipFile zip,
JsonNode assetsNode,
Set<String> entryNames,
String bundleFile,
long bundleLength) throws Exception {
if (assetsNode == null || !assetsNode.isArray()) {
throw new IllegalArgumentException("manifest assets must be an array");
}
Map<String, AssetIdentity> declared = new HashMap<>();
long declaredTotal = bundleLength;
for (JsonNode asset : assetsNode) {
if (!asset.isObject()) {
throw new IllegalArgumentException("manifest asset must be an object");
}
Set<String> fields = new HashSet<>();
asset.fieldNames().forEachRemaining(fields::add);
if (!fields.equals(ASSET_FIELDS)) {
throw new IllegalArgumentException("manifest asset fields are invalid");
}
String relativePath = requiredText(asset, "path");
validateEntryName(relativePath);
String archivePath = "assets/" + relativePath;
validateEntryName(archivePath);
long byteLength = requiredNonNegativeLong(asset, "byteLength");
if (byteLength > MAX_ENTRY_BYTES) {
throw new IllegalArgumentException("manifest asset is too large");
}
String sha = requiredSha256(asset, "sha256");
if (declared.putIfAbsent(archivePath, new AssetIdentity(byteLength, sha)) != null) {
throw new IllegalArgumentException("duplicate manifest asset path");
}
declaredTotal = Math.addExact(declaredTotal, byteLength);
if (declaredTotal > MAX_TOTAL_UNCOMPRESSED_BYTES) {
throw new IllegalArgumentException("manifest assets are too large");
}
}
Set<String> expectedEntries = new HashSet<>(declared.keySet());
expectedEntries.add(MANIFEST_NAME);
expectedEntries.add(bundleFile);
if (!entryNames.equals(expectedEntries)) {
throw new IllegalArgumentException("ZIP contains undeclared or missing assets");
}
for (Map.Entry<String, AssetIdentity> asset : declared.entrySet()) {
ZipArchiveEntry entry = zip.getEntry(asset.getKey());
if (entry == null || entry.isDirectory() || entry.isUnixSymlink()) {
throw new IllegalArgumentException("manifest asset is missing from ZIP");
}
DigestResult actual;
try (InputStream input = zip.getInputStream(entry)) {
actual = sha256(input, MAX_ENTRY_BYTES);
}
if (actual.byteLength() != asset.getValue().byteLength()
|| actual.byteLength() != entry.getSize()
|| !actual.sha256().equals(asset.getValue().sha256())) {
throw new IllegalArgumentException("manifest asset does not match ZIP content");
}
}
}
private void requireInsideUploadRoot(Path path) {
if (!path.startsWith(uploadRoot)) {
throw new IllegalArgumentException("RN bundle path escapes the upload directory");
}
}
private static void requireSegment(String value, String field) {
if (value == null || !value.matches("[A-Za-z0-9._-]+")) {
throw new IllegalArgumentException(field + " is invalid");
}
}
private static RnBundleEntity.ModuleType parseType(String value) {
try {
return RnBundleEntity.ModuleType.valueOf(value.toUpperCase(Locale.ROOT));
} catch (Exception e) {
throw new IllegalArgumentException("manifest type must be common, app or buz");
}
}
private static RnBundleEntity.Platform parsePlatform(String value) {
try {
return RnBundleEntity.Platform.valueOf(value.toUpperCase(Locale.ROOT));
} catch (Exception e) {
throw new IllegalArgumentException("manifest platform must be android or ios");
}
}
private static String requiredText(JsonNode node, String field) {
String value = optionalText(node, field);
if (value == null) throw new IllegalArgumentException("manifest " + field + " is required");
return value;
}
private static String optionalText(JsonNode node, String field) {
JsonNode value = node.get(field);
if (value == null || value.isNull()) return null;
if (!value.isTextual() || value.textValue().isBlank()) {
throw new IllegalArgumentException("manifest " + field + " must be a non-empty string");
}
return value.textValue().trim();
}
private static int requiredPositiveInt(JsonNode node, String field) {
JsonNode value = node.get(field);
if (value == null || !value.isIntegralNumber() || !value.canConvertToInt()
|| value.intValue() < 1) {
throw new IllegalArgumentException("manifest " + field + " must be a positive integer");
}
return value.intValue();
}
private static long requiredNonNegativeLong(JsonNode node, String field) {
JsonNode value = node.get(field);
if (value == null || !value.isIntegralNumber() || !value.canConvertToLong()
|| value.longValue() < 0) {
throw new IllegalArgumentException("manifest " + field + " must be a non-negative integer");
}
return value.longValue();
}
private static String requiredSha256(JsonNode node, String field) {
String value = requiredText(node, field).toLowerCase(Locale.ROOT);
if (!value.matches("[a-f0-9]{64}")) {
throw new IllegalArgumentException("manifest " + field + " must be SHA-256");
}
return value;
}
private static String sha256(InputStream input) throws Exception {
return sha256(input, Long.MAX_VALUE).sha256();
}
private static DigestResult sha256(InputStream input, long limit) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
long byteLength = 0;
try (DigestInputStream digestInput = new DigestInputStream(input, digest)) {
byte[] buffer = new byte[8192];
int read;
while ((read = digestInput.read(buffer)) >= 0) {
byteLength = Math.addExact(byteLength, read);
if (byteLength > limit) {
throw new IllegalArgumentException("ZIP entry expands beyond its allowed size");
}
}
}
return new DigestResult(HexFormat.of().formatHex(digest.digest()), byteLength);
}
private record AssetIdentity(long byteLength, String sha256) {
}
private record DigestResult(String sha256, long byteLength) {
}
}