464 行
22 KiB
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) {
|
|
}
|
|
}
|