mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
fix(publish): accept Windows zip directory entries
Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>
This commit is contained in:
parent
e8cab7389f
commit
cdadcf480a
4 changed files with 410 additions and 363 deletions
|
|
@ -1,216 +1,224 @@
|
|||
package com.iflytek.skillhub.controller.support;
|
||||
|
||||
import com.iflytek.skillhub.config.SkillPublishProperties;
|
||||
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
|
||||
import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
@Component
|
||||
public class SkillPackageArchiveExtractor {
|
||||
|
||||
public record ExtractionResult(List<PackageEntry> entries, List<String> warnings) {}
|
||||
|
||||
private final long maxTotalPackageSize;
|
||||
private final long maxSingleFileSize;
|
||||
private final int maxFileCount;
|
||||
|
||||
public SkillPackageArchiveExtractor(SkillPublishProperties properties) {
|
||||
this.maxTotalPackageSize = properties.getMaxPackageSize();
|
||||
this.maxSingleFileSize = properties.getMaxSingleFileSize();
|
||||
this.maxFileCount = properties.getMaxFileCount();
|
||||
}
|
||||
|
||||
public List<PackageEntry> extract(MultipartFile file) throws IOException {
|
||||
if (file.getSize() > maxTotalPackageSize) {
|
||||
throw new IllegalArgumentException(
|
||||
"Package too large: " + file.getSize() + " bytes (max: "
|
||||
+ maxTotalPackageSize + ")"
|
||||
);
|
||||
}
|
||||
|
||||
List<PackageEntry> entries = new ArrayList<>();
|
||||
long totalSize = 0;
|
||||
|
||||
try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) {
|
||||
ZipEntry zipEntry;
|
||||
while ((zipEntry = zis.getNextEntry()) != null) {
|
||||
if (zipEntry.isDirectory()) {
|
||||
zis.closeEntry();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isOsMetadataEntry(zipEntry.getName())) {
|
||||
zis.closeEntry();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entries.size() >= maxFileCount) {
|
||||
throw new IllegalArgumentException(
|
||||
"Too many files: more than " + maxFileCount
|
||||
);
|
||||
}
|
||||
|
||||
String normalizedPath = SkillPackagePolicy.normalizeEntryPath(zipEntry.getName());
|
||||
byte[] content = readEntry(zis, normalizedPath);
|
||||
totalSize += content.length;
|
||||
if (totalSize > maxTotalPackageSize) {
|
||||
throw new IllegalArgumentException(
|
||||
"Package too large: " + totalSize + " bytes (max: "
|
||||
+ maxTotalPackageSize + ")"
|
||||
);
|
||||
}
|
||||
|
||||
entries.add(new PackageEntry(
|
||||
normalizedPath,
|
||||
content,
|
||||
content.length,
|
||||
determineContentType(normalizedPath)
|
||||
));
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
|
||||
return stripSingleRootDirectory(entries);
|
||||
}
|
||||
|
||||
public ExtractionResult extractWithWarnings(MultipartFile file) throws IOException {
|
||||
List<PackageEntry> entries = extract(file);
|
||||
return promoteSingleSkillMdDirectory(entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* If all file paths share a single root directory prefix (e.g., "my-skill/xxx"),
|
||||
* strip that prefix. Otherwise return entries unchanged.
|
||||
*/
|
||||
static List<PackageEntry> stripSingleRootDirectory(List<PackageEntry> entries) {
|
||||
if (entries.isEmpty()) return entries;
|
||||
|
||||
Set<String> rootSegments = new HashSet<>();
|
||||
for (PackageEntry entry : entries) {
|
||||
int slashIndex = entry.path().indexOf('/');
|
||||
if (slashIndex < 0) {
|
||||
// File at root level, no stripping
|
||||
return entries;
|
||||
}
|
||||
rootSegments.add(entry.path().substring(0, slashIndex));
|
||||
}
|
||||
|
||||
if (rootSegments.size() != 1) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
String prefix = rootSegments.iterator().next() + "/";
|
||||
return entries.stream()
|
||||
.map(e -> new PackageEntry(
|
||||
e.path().substring(prefix.length()),
|
||||
e.content(),
|
||||
e.size(),
|
||||
e.contentType()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
static ExtractionResult promoteSingleSkillMdDirectory(List<PackageEntry> entries) {
|
||||
boolean hasRootSkillMd = entries.stream()
|
||||
.anyMatch(e -> SkillPackagePolicy.SKILL_MD_PATH.equals(e.path()));
|
||||
if (hasRootSkillMd) {
|
||||
return new ExtractionResult(entries, List.of());
|
||||
}
|
||||
|
||||
Set<String> skillMdDirs = new HashSet<>();
|
||||
for (PackageEntry entry : entries) {
|
||||
int slashIndex = entry.path().indexOf('/');
|
||||
if (slashIndex > 0) {
|
||||
String relativePath = entry.path().substring(slashIndex + 1);
|
||||
if (SkillPackagePolicy.SKILL_MD_PATH.equals(relativePath)) {
|
||||
skillMdDirs.add(entry.path().substring(0, slashIndex));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (skillMdDirs.isEmpty()) {
|
||||
return new ExtractionResult(entries, List.of());
|
||||
}
|
||||
if (skillMdDirs.size() > 1) {
|
||||
throw new IllegalArgumentException(
|
||||
"Ambiguous package: SKILL.md found in multiple directories: " + skillMdDirs);
|
||||
}
|
||||
|
||||
String prefix = skillMdDirs.iterator().next() + "/";
|
||||
List<PackageEntry> promoted = new ArrayList<>();
|
||||
List<String> warnings = new ArrayList<>();
|
||||
|
||||
for (PackageEntry entry : entries) {
|
||||
if (entry.path().startsWith(prefix)) {
|
||||
promoted.add(new PackageEntry(
|
||||
entry.path().substring(prefix.length()),
|
||||
entry.content(),
|
||||
entry.size(),
|
||||
entry.contentType()));
|
||||
} else {
|
||||
warnings.add("Ignored file outside skill directory: " + entry.path());
|
||||
}
|
||||
}
|
||||
|
||||
return new ExtractionResult(promoted, warnings);
|
||||
}
|
||||
|
||||
private static boolean isOsMetadataEntry(String name) {
|
||||
String normalized = name.replace('\\', '/');
|
||||
if (normalized.startsWith("__MACOSX/") || normalized.equals("__MACOSX")) return true;
|
||||
String fileName = normalized.contains("/") ? normalized.substring(normalized.lastIndexOf('/') + 1) : normalized;
|
||||
return fileName.equals(".DS_Store") || fileName.startsWith("._");
|
||||
}
|
||||
|
||||
private byte[] readEntry(ZipInputStream zis, String path) throws IOException {
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[8192];
|
||||
long totalRead = 0;
|
||||
int read;
|
||||
while ((read = zis.read(buffer)) != -1) {
|
||||
totalRead += read;
|
||||
if (totalRead > maxSingleFileSize) {
|
||||
throw new IllegalArgumentException(
|
||||
"File too large: " + path + " (" + totalRead + " bytes, max: "
|
||||
+ maxSingleFileSize + ")"
|
||||
);
|
||||
}
|
||||
outputStream.write(buffer, 0, read);
|
||||
}
|
||||
return outputStream.toByteArray();
|
||||
}
|
||||
|
||||
private String determineContentType(String filename) {
|
||||
String lower = filename.toLowerCase();
|
||||
if (lower.endsWith(".py")) return "text/x-python";
|
||||
if (lower.endsWith(".json")) return "application/json";
|
||||
if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/x-yaml";
|
||||
if (lower.endsWith(".txt")) return "text/plain";
|
||||
if (lower.endsWith(".md")) return "text/markdown";
|
||||
if (lower.endsWith(".html")) return "text/html";
|
||||
if (lower.endsWith(".css")) return "text/css";
|
||||
if (lower.endsWith(".csv")) return "text/csv";
|
||||
if (lower.endsWith(".xml")) return "application/xml";
|
||||
if (lower.endsWith(".js") || lower.endsWith(".cjs") || lower.endsWith(".mjs")) return "text/javascript";
|
||||
if (lower.endsWith(".ts")) return "text/typescript";
|
||||
if (lower.endsWith(".sh") || lower.endsWith(".bash") || lower.endsWith(".zsh")) return "text/x-shellscript";
|
||||
if (lower.endsWith(".png")) return "image/png";
|
||||
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (lower.endsWith(".gif")) return "image/gif";
|
||||
if (lower.endsWith(".svg")) return "image/svg+xml";
|
||||
if (lower.endsWith(".webp")) return "image/webp";
|
||||
if (lower.endsWith(".ico")) return "image/x-icon";
|
||||
if (lower.endsWith(".pdf")) return "application/pdf";
|
||||
if (lower.endsWith(".toml")) return "application/toml";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
package com.iflytek.skillhub.controller.support;
|
||||
|
||||
import com.iflytek.skillhub.config.SkillPublishProperties;
|
||||
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
|
||||
import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
@Component
|
||||
public class SkillPackageArchiveExtractor {
|
||||
|
||||
public record ExtractionResult(List<PackageEntry> entries, List<String> warnings) {}
|
||||
|
||||
private final long maxTotalPackageSize;
|
||||
private final long maxSingleFileSize;
|
||||
private final int maxFileCount;
|
||||
|
||||
public SkillPackageArchiveExtractor(SkillPublishProperties properties) {
|
||||
this.maxTotalPackageSize = properties.getMaxPackageSize();
|
||||
this.maxSingleFileSize = properties.getMaxSingleFileSize();
|
||||
this.maxFileCount = properties.getMaxFileCount();
|
||||
}
|
||||
|
||||
public List<PackageEntry> extract(MultipartFile file) throws IOException {
|
||||
if (file.getSize() > maxTotalPackageSize) {
|
||||
throw new IllegalArgumentException(
|
||||
"Package too large: " + file.getSize() + " bytes (max: "
|
||||
+ maxTotalPackageSize + ")"
|
||||
);
|
||||
}
|
||||
|
||||
List<PackageEntry> entries = new ArrayList<>();
|
||||
long totalSize = 0;
|
||||
|
||||
try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) {
|
||||
ZipEntry zipEntry;
|
||||
while ((zipEntry = zis.getNextEntry()) != null) {
|
||||
if (isDirectoryEntry(zipEntry)) {
|
||||
zis.closeEntry();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isOsMetadataEntry(zipEntry.getName())) {
|
||||
zis.closeEntry();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entries.size() >= maxFileCount) {
|
||||
throw new IllegalArgumentException(
|
||||
"Too many files: more than " + maxFileCount
|
||||
);
|
||||
}
|
||||
|
||||
String normalizedPath = SkillPackagePolicy.normalizeEntryPath(zipEntry.getName());
|
||||
byte[] content = readEntry(zis, normalizedPath);
|
||||
totalSize += content.length;
|
||||
if (totalSize > maxTotalPackageSize) {
|
||||
throw new IllegalArgumentException(
|
||||
"Package too large: " + totalSize + " bytes (max: "
|
||||
+ maxTotalPackageSize + ")"
|
||||
);
|
||||
}
|
||||
|
||||
entries.add(new PackageEntry(
|
||||
normalizedPath,
|
||||
content,
|
||||
content.length,
|
||||
determineContentType(normalizedPath)
|
||||
));
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
|
||||
return stripSingleRootDirectory(entries);
|
||||
}
|
||||
|
||||
public ExtractionResult extractWithWarnings(MultipartFile file) throws IOException {
|
||||
List<PackageEntry> entries = extract(file);
|
||||
return promoteSingleSkillMdDirectory(entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* If all file paths share a single root directory prefix (e.g., "my-skill/xxx"),
|
||||
* strip that prefix. Otherwise return entries unchanged.
|
||||
*/
|
||||
static List<PackageEntry> stripSingleRootDirectory(List<PackageEntry> entries) {
|
||||
if (entries.isEmpty()) return entries;
|
||||
|
||||
Set<String> rootSegments = new HashSet<>();
|
||||
for (PackageEntry entry : entries) {
|
||||
int slashIndex = entry.path().indexOf('/');
|
||||
if (slashIndex < 0) {
|
||||
// File at root level, no stripping
|
||||
return entries;
|
||||
}
|
||||
rootSegments.add(entry.path().substring(0, slashIndex));
|
||||
}
|
||||
|
||||
if (rootSegments.size() != 1) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
String prefix = rootSegments.iterator().next() + "/";
|
||||
return entries.stream()
|
||||
.map(e -> new PackageEntry(
|
||||
e.path().substring(prefix.length()),
|
||||
e.content(),
|
||||
e.size(),
|
||||
e.contentType()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
static ExtractionResult promoteSingleSkillMdDirectory(List<PackageEntry> entries) {
|
||||
boolean hasRootSkillMd = entries.stream()
|
||||
.anyMatch(e -> SkillPackagePolicy.SKILL_MD_PATH.equals(e.path()));
|
||||
if (hasRootSkillMd) {
|
||||
return new ExtractionResult(entries, List.of());
|
||||
}
|
||||
|
||||
Set<String> skillMdDirs = new HashSet<>();
|
||||
for (PackageEntry entry : entries) {
|
||||
int slashIndex = entry.path().indexOf('/');
|
||||
if (slashIndex > 0) {
|
||||
String relativePath = entry.path().substring(slashIndex + 1);
|
||||
if (SkillPackagePolicy.SKILL_MD_PATH.equals(relativePath)) {
|
||||
skillMdDirs.add(entry.path().substring(0, slashIndex));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (skillMdDirs.isEmpty()) {
|
||||
return new ExtractionResult(entries, List.of());
|
||||
}
|
||||
if (skillMdDirs.size() > 1) {
|
||||
throw new IllegalArgumentException(
|
||||
"Ambiguous package: SKILL.md found in multiple directories: " + skillMdDirs);
|
||||
}
|
||||
|
||||
String prefix = skillMdDirs.iterator().next() + "/";
|
||||
List<PackageEntry> promoted = new ArrayList<>();
|
||||
List<String> warnings = new ArrayList<>();
|
||||
|
||||
for (PackageEntry entry : entries) {
|
||||
if (entry.path().startsWith(prefix)) {
|
||||
promoted.add(new PackageEntry(
|
||||
entry.path().substring(prefix.length()),
|
||||
entry.content(),
|
||||
entry.size(),
|
||||
entry.contentType()));
|
||||
} else {
|
||||
warnings.add("Ignored file outside skill directory: " + entry.path());
|
||||
}
|
||||
}
|
||||
|
||||
return new ExtractionResult(promoted, warnings);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link ZipEntry#isDirectory()} only recognizes the ZIP-standard forward slash. Some Windows
|
||||
* archive tools emit directory entries whose names end in a backslash instead.
|
||||
*/
|
||||
static boolean isDirectoryEntry(ZipEntry entry) {
|
||||
return entry.isDirectory() || entry.getName().endsWith("\\");
|
||||
}
|
||||
|
||||
private static boolean isOsMetadataEntry(String name) {
|
||||
String normalized = name.replace('\\', '/');
|
||||
if (normalized.startsWith("__MACOSX/") || normalized.equals("__MACOSX")) return true;
|
||||
String fileName = normalized.contains("/") ? normalized.substring(normalized.lastIndexOf('/') + 1) : normalized;
|
||||
return fileName.equals(".DS_Store") || fileName.startsWith("._");
|
||||
}
|
||||
|
||||
private byte[] readEntry(ZipInputStream zis, String path) throws IOException {
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[8192];
|
||||
long totalRead = 0;
|
||||
int read;
|
||||
while ((read = zis.read(buffer)) != -1) {
|
||||
totalRead += read;
|
||||
if (totalRead > maxSingleFileSize) {
|
||||
throw new IllegalArgumentException(
|
||||
"File too large: " + path + " (" + totalRead + " bytes, max: "
|
||||
+ maxSingleFileSize + ")"
|
||||
);
|
||||
}
|
||||
outputStream.write(buffer, 0, read);
|
||||
}
|
||||
return outputStream.toByteArray();
|
||||
}
|
||||
|
||||
private String determineContentType(String filename) {
|
||||
String lower = filename.toLowerCase();
|
||||
if (lower.endsWith(".py")) return "text/x-python";
|
||||
if (lower.endsWith(".json")) return "application/json";
|
||||
if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/x-yaml";
|
||||
if (lower.endsWith(".txt")) return "text/plain";
|
||||
if (lower.endsWith(".md")) return "text/markdown";
|
||||
if (lower.endsWith(".html")) return "text/html";
|
||||
if (lower.endsWith(".css")) return "text/css";
|
||||
if (lower.endsWith(".csv")) return "text/csv";
|
||||
if (lower.endsWith(".xml")) return "application/xml";
|
||||
if (lower.endsWith(".js") || lower.endsWith(".cjs") || lower.endsWith(".mjs")) return "text/javascript";
|
||||
if (lower.endsWith(".ts")) return "text/typescript";
|
||||
if (lower.endsWith(".sh") || lower.endsWith(".bash") || lower.endsWith(".zsh")) return "text/x-shellscript";
|
||||
if (lower.endsWith(".png")) return "image/png";
|
||||
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (lower.endsWith(".gif")) return "image/gif";
|
||||
if (lower.endsWith(".svg")) return "image/svg+xml";
|
||||
if (lower.endsWith(".webp")) return "image/webp";
|
||||
if (lower.endsWith(".ico")) return "image/x-icon";
|
||||
if (lower.endsWith(".pdf")) return "application/pdf";
|
||||
if (lower.endsWith(".toml")) return "application/toml";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,147 +1,147 @@
|
|||
package com.iflytek.skillhub.controller.support;
|
||||
|
||||
import com.iflytek.skillhub.config.SkillPublishProperties;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
|
||||
import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
/**
|
||||
* Extracts zip uploads into validated package entries that can be consumed by the publish flow.
|
||||
*/
|
||||
@Component
|
||||
public class ZipPackageExtractor {
|
||||
|
||||
private static final int BUFFER_SIZE = 8192;
|
||||
|
||||
private final SkillPublishProperties properties;
|
||||
|
||||
public ZipPackageExtractor(SkillPublishProperties properties) {
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
public List<PackageEntry> extract(MultipartFile file) throws IOException {
|
||||
List<PackageEntry> entries = new ArrayList<>();
|
||||
Set<String> seenPaths = new HashSet<>();
|
||||
long totalSize = 0L;
|
||||
|
||||
try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) {
|
||||
ZipEntry zipEntry;
|
||||
while ((zipEntry = zis.getNextEntry()) != null) {
|
||||
if (zipEntry.isDirectory()) {
|
||||
zis.closeEntry();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entries.size() >= properties.getMaxFileCount()) {
|
||||
throw new DomainBadRequestException("error.skill.publish.package.invalid",
|
||||
"Too many files: max " + properties.getMaxFileCount());
|
||||
}
|
||||
|
||||
String normalizedPath = normalizeEntryPath(zipEntry.getName());
|
||||
if (!seenPaths.add(normalizedPath)) {
|
||||
throw new DomainBadRequestException("error.skill.publish.package.invalid",
|
||||
"Duplicate package path: " + normalizedPath);
|
||||
}
|
||||
|
||||
byte[] content = readEntry(zis, normalizedPath);
|
||||
totalSize += content.length;
|
||||
if (totalSize > properties.getMaxPackageSize()) {
|
||||
throw new DomainBadRequestException("error.skill.publish.package.invalid",
|
||||
"Package too large: max " + properties.getMaxPackageSize() + " bytes");
|
||||
}
|
||||
|
||||
entries.add(new PackageEntry(
|
||||
normalizedPath,
|
||||
content,
|
||||
content.length,
|
||||
determineContentType(normalizedPath)
|
||||
));
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
|
||||
return SkillPackageArchiveExtractor.stripSingleRootDirectory(entries);
|
||||
}
|
||||
|
||||
private byte[] readEntry(ZipInputStream zis, String path) throws IOException {
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[BUFFER_SIZE];
|
||||
int read;
|
||||
long fileSize = 0L;
|
||||
while ((read = zis.read(buffer)) != -1) {
|
||||
fileSize += read;
|
||||
if (fileSize > properties.getMaxSingleFileSize()) {
|
||||
throw new DomainBadRequestException("error.skill.publish.package.invalid",
|
||||
"File too large: " + path + " (max " + properties.getMaxSingleFileSize() + " bytes)");
|
||||
}
|
||||
outputStream.write(buffer, 0, read);
|
||||
}
|
||||
return outputStream.toByteArray();
|
||||
}
|
||||
|
||||
private String normalizeEntryPath(String path) {
|
||||
if (path == null || path.isBlank()) {
|
||||
throw new DomainBadRequestException("error.skill.publish.package.invalid", "Package entry path is blank");
|
||||
}
|
||||
if (path.contains("\\")) {
|
||||
throw new DomainBadRequestException("error.skill.publish.package.invalid",
|
||||
"Package entry must use '/' separators: " + path);
|
||||
}
|
||||
|
||||
try {
|
||||
Path normalized = Path.of(path).normalize();
|
||||
String normalizedPath = normalized.toString().replace('\\', '/');
|
||||
if (normalized.isAbsolute()
|
||||
|| normalizedPath.isBlank()
|
||||
|| normalizedPath.startsWith("../")
|
||||
|| normalizedPath.equals("..")
|
||||
|| path.startsWith("/")
|
||||
|| path.contains("//")) {
|
||||
throw new DomainBadRequestException("error.skill.publish.package.invalid",
|
||||
"Unsafe package path: " + path);
|
||||
}
|
||||
return SkillPackagePolicy.canonicalizeSkillMdPath(normalizedPath);
|
||||
} catch (InvalidPathException ex) {
|
||||
throw new DomainBadRequestException("error.skill.publish.package.invalid",
|
||||
"Invalid package path: " + path);
|
||||
}
|
||||
}
|
||||
|
||||
private String determineContentType(String filename) {
|
||||
String lower = filename.toLowerCase();
|
||||
if (lower.endsWith(".py")) return "text/x-python";
|
||||
if (lower.endsWith(".json")) return "application/json";
|
||||
if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/x-yaml";
|
||||
if (lower.endsWith(".txt")) return "text/plain";
|
||||
if (lower.endsWith(".md")) return "text/markdown";
|
||||
if (lower.endsWith(".html")) return "text/html";
|
||||
if (lower.endsWith(".css")) return "text/css";
|
||||
if (lower.endsWith(".csv")) return "text/csv";
|
||||
if (lower.endsWith(".xml")) return "application/xml";
|
||||
if (lower.endsWith(".js")) return "text/javascript";
|
||||
if (lower.endsWith(".ts")) return "text/typescript";
|
||||
if (lower.endsWith(".sh") || lower.endsWith(".bash") || lower.endsWith(".zsh")) return "text/x-shellscript";
|
||||
if (lower.endsWith(".png")) return "image/png";
|
||||
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (lower.endsWith(".gif")) return "image/gif";
|
||||
if (lower.endsWith(".svg")) return "image/svg+xml";
|
||||
if (lower.endsWith(".webp")) return "image/webp";
|
||||
if (lower.endsWith(".ico")) return "image/x-icon";
|
||||
if (lower.endsWith(".pdf")) return "application/pdf";
|
||||
if (lower.endsWith(".toml")) return "application/toml";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
package com.iflytek.skillhub.controller.support;
|
||||
|
||||
import com.iflytek.skillhub.config.SkillPublishProperties;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
|
||||
import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
/**
|
||||
* Extracts zip uploads into validated package entries that can be consumed by the publish flow.
|
||||
*/
|
||||
@Component
|
||||
public class ZipPackageExtractor {
|
||||
|
||||
private static final int BUFFER_SIZE = 8192;
|
||||
|
||||
private final SkillPublishProperties properties;
|
||||
|
||||
public ZipPackageExtractor(SkillPublishProperties properties) {
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
public List<PackageEntry> extract(MultipartFile file) throws IOException {
|
||||
List<PackageEntry> entries = new ArrayList<>();
|
||||
Set<String> seenPaths = new HashSet<>();
|
||||
long totalSize = 0L;
|
||||
|
||||
try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) {
|
||||
ZipEntry zipEntry;
|
||||
while ((zipEntry = zis.getNextEntry()) != null) {
|
||||
if (SkillPackageArchiveExtractor.isDirectoryEntry(zipEntry)) {
|
||||
zis.closeEntry();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entries.size() >= properties.getMaxFileCount()) {
|
||||
throw new DomainBadRequestException("error.skill.publish.package.invalid",
|
||||
"Too many files: max " + properties.getMaxFileCount());
|
||||
}
|
||||
|
||||
String normalizedPath = normalizeEntryPath(zipEntry.getName());
|
||||
if (!seenPaths.add(normalizedPath)) {
|
||||
throw new DomainBadRequestException("error.skill.publish.package.invalid",
|
||||
"Duplicate package path: " + normalizedPath);
|
||||
}
|
||||
|
||||
byte[] content = readEntry(zis, normalizedPath);
|
||||
totalSize += content.length;
|
||||
if (totalSize > properties.getMaxPackageSize()) {
|
||||
throw new DomainBadRequestException("error.skill.publish.package.invalid",
|
||||
"Package too large: max " + properties.getMaxPackageSize() + " bytes");
|
||||
}
|
||||
|
||||
entries.add(new PackageEntry(
|
||||
normalizedPath,
|
||||
content,
|
||||
content.length,
|
||||
determineContentType(normalizedPath)
|
||||
));
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
|
||||
return SkillPackageArchiveExtractor.stripSingleRootDirectory(entries);
|
||||
}
|
||||
|
||||
private byte[] readEntry(ZipInputStream zis, String path) throws IOException {
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[BUFFER_SIZE];
|
||||
int read;
|
||||
long fileSize = 0L;
|
||||
while ((read = zis.read(buffer)) != -1) {
|
||||
fileSize += read;
|
||||
if (fileSize > properties.getMaxSingleFileSize()) {
|
||||
throw new DomainBadRequestException("error.skill.publish.package.invalid",
|
||||
"File too large: " + path + " (max " + properties.getMaxSingleFileSize() + " bytes)");
|
||||
}
|
||||
outputStream.write(buffer, 0, read);
|
||||
}
|
||||
return outputStream.toByteArray();
|
||||
}
|
||||
|
||||
private String normalizeEntryPath(String path) {
|
||||
if (path == null || path.isBlank()) {
|
||||
throw new DomainBadRequestException("error.skill.publish.package.invalid", "Package entry path is blank");
|
||||
}
|
||||
if (path.contains("\\")) {
|
||||
throw new DomainBadRequestException("error.skill.publish.package.invalid",
|
||||
"Package entry must use '/' separators: " + path);
|
||||
}
|
||||
|
||||
try {
|
||||
Path normalized = Path.of(path).normalize();
|
||||
String normalizedPath = normalized.toString().replace('\\', '/');
|
||||
if (normalized.isAbsolute()
|
||||
|| normalizedPath.isBlank()
|
||||
|| normalizedPath.startsWith("../")
|
||||
|| normalizedPath.equals("..")
|
||||
|| path.startsWith("/")
|
||||
|| path.contains("//")) {
|
||||
throw new DomainBadRequestException("error.skill.publish.package.invalid",
|
||||
"Unsafe package path: " + path);
|
||||
}
|
||||
return SkillPackagePolicy.canonicalizeSkillMdPath(normalizedPath);
|
||||
} catch (InvalidPathException ex) {
|
||||
throw new DomainBadRequestException("error.skill.publish.package.invalid",
|
||||
"Invalid package path: " + path);
|
||||
}
|
||||
}
|
||||
|
||||
private String determineContentType(String filename) {
|
||||
String lower = filename.toLowerCase();
|
||||
if (lower.endsWith(".py")) return "text/x-python";
|
||||
if (lower.endsWith(".json")) return "application/json";
|
||||
if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/x-yaml";
|
||||
if (lower.endsWith(".txt")) return "text/plain";
|
||||
if (lower.endsWith(".md")) return "text/markdown";
|
||||
if (lower.endsWith(".html")) return "text/html";
|
||||
if (lower.endsWith(".css")) return "text/css";
|
||||
if (lower.endsWith(".csv")) return "text/csv";
|
||||
if (lower.endsWith(".xml")) return "application/xml";
|
||||
if (lower.endsWith(".js")) return "text/javascript";
|
||||
if (lower.endsWith(".ts")) return "text/typescript";
|
||||
if (lower.endsWith(".sh") || lower.endsWith(".bash") || lower.endsWith(".zsh")) return "text/x-shellscript";
|
||||
if (lower.endsWith(".png")) return "image/png";
|
||||
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (lower.endsWith(".gif")) return "image/gif";
|
||||
if (lower.endsWith(".svg")) return "image/svg+xml";
|
||||
if (lower.endsWith(".webp")) return "image/webp";
|
||||
if (lower.endsWith(".ico")) return "image/x-icon";
|
||||
if (lower.endsWith(".pdf")) return "application/pdf";
|
||||
if (lower.endsWith(".toml")) return "application/toml";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,6 +129,25 @@ class SkillPackageArchiveExtractorTest {
|
|||
assertEquals("SKILL.md", entries.get(0).path());
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsWindowsStyleDirectoryEntries() throws Exception {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
|
||||
zos.putNextEntry(new ZipEntry("my-skill\\"));
|
||||
zos.closeEntry();
|
||||
zos.putNextEntry(new ZipEntry("my-skill\\SKILL.md"));
|
||||
zos.write("---\nname: test\n---".getBytes());
|
||||
zos.closeEntry();
|
||||
}
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file", "test.zip", "application/zip", baos.toByteArray());
|
||||
|
||||
List<PackageEntry> entries = extractor.extract(file);
|
||||
|
||||
assertEquals(1, entries.size());
|
||||
assertEquals("SKILL.md", entries.get(0).path());
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotStripWhenMultipleRootDirectories() throws Exception {
|
||||
byte[] zipBytes = createZip(Map.of(
|
||||
|
|
|
|||
|
|
@ -32,6 +32,26 @@ class ZipPackageExtractorTest {
|
|||
assertTrue(entries.stream().noneMatch(e -> e.path().equals("skill.md")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsWindowsStyleDirectoryEntries() throws Exception {
|
||||
ZipPackageExtractor extractor = new ZipPackageExtractor(new SkillPublishProperties());
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
|
||||
zos.putNextEntry(new ZipEntry("my-skill\\"));
|
||||
zos.closeEntry();
|
||||
zos.putNextEntry(new ZipEntry("my-skill/SKILL.md"));
|
||||
zos.write("---\nname: test\n---\n".getBytes());
|
||||
zos.closeEntry();
|
||||
}
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file", "test.zip", "application/zip", baos.toByteArray());
|
||||
|
||||
List<PackageEntry> entries = extractor.extract(file);
|
||||
|
||||
assertEquals(1, entries.size());
|
||||
assertEquals("SKILL.md", entries.get(0).path());
|
||||
}
|
||||
|
||||
private byte[] createZip(Map<String, byte[]> entries) throws Exception {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue