fix(compat): percent-encode ClawHub download redirect Location

Downloading a skill whose slug is non-ASCII (e.g. a Chinese name) through
the ClawHub CLI compatibility route failed: the 302 Location header was
built by string-concatenating the raw slug, and Tomcat encodes header
values as ISO-8859-1, so a character outside 0-255 makes it drop the
Location header entirely and the download breaks. The skillhub CLI path
was unaffected because it doesn't go through this redirect.

Build the Location with UriComponentsBuilder.pathSegment(...).encode(), so
each segment is percent-encoded while the '/' separators stay literal.
"需求" becomes %E9%9C%80%E6%B1%82 and the header is ISO-8859-1-writable.

Fixes #658

Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>
(cherry picked from commit 34dc4fa29c)
This commit is contained in:
FenjuFu 2026-08-02 02:18:36 +08:00 • committed by XiaoSeS
parent 10d8090f97
commit 9054fb6ee2
2 changed files with 65 additions and 6 deletions

View file

@ -30,6 +30,7 @@ import org.slf4j.MDC;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.util.UriComponentsBuilder;
/**
* Compatibility-focused application service that keeps ClawHub transport logic
@ -131,9 +132,7 @@ public class ClawHubCompatAppService {
public String downloadLocationByPath(String canonicalSlug, String version) {
SkillCoordinate coord = mapper.fromCanonical(canonicalSlug);
return "latest".equals(version)
? "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/download"
: "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download";
return buildDownloadLocation(coord, version);
}
public String downloadLocationByQuery(String slug,
@ -141,9 +140,28 @@ public class ClawHubCompatAppService {
String userId,
Map<Long, NamespaceRole> userNsRoles) {
SkillCoordinate coord = resolveQueryCoordinate(slug, userId, userNsRoles);
return "latest".equals(version)
? "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/download"
: "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download";
return buildDownloadLocation(coord, version);
}
/**
* Builds the redirect Location for a download.
*
* <p>
* Each path segment is percent-encoded, because a non-ASCII slug (for example a
* Chinese skill name) cannot be written into the HTTP {@code Location} header as-is:
* Tomcat encodes header values as ISO-8859-1 and drops the header when a character
* falls outside 0-255, which breaks the ClawHub CLI download. Using
* {@code pathSegment(...)} keeps the '/' separators literal while encoding the
* segment contents.
*/
private String buildDownloadLocation(SkillCoordinate coord, String version) {
UriComponentsBuilder builder = UriComponentsBuilder.fromPath("/api/v1/skills")
.pathSegment(coord.namespace(), coord.slug());
if (!"latest".equals(version)) {
builder.pathSegment("versions", version);
}
builder.pathSegment("download");
return builder.encode().toUriString();
}
private SkillCoordinate resolveQueryCoordinate(String slug,

View file

@ -77,4 +77,45 @@ class ClawHubCompatAppServiceTest {
assertThat(location).isEqualTo("/api/v1/skills/team-a/my-skill/download");
}
@Test
void downloadLocationByQuery_percentEncodesNonAsciiSlug() {
// A non-ASCII slug (e.g. a Chinese skill name) must be percent-encoded, otherwise
// Tomcat drops the Location header (ISO-8859-1 only) and the ClawHub CLI download fails.
Namespace namespace = new Namespace("global", "Global", "owner-1");
Skill cjkSkill = new Skill(1L, "需求", "owner-1", SkillVisibility.PUBLIC);
CompatSkillLookupService.CompatSkillContext context = new CompatSkillLookupService.CompatSkillContext(
namespace,
cjkSkill,
Optional.empty()
);
when(compatSkillLookupService.findByLegacySlug("需求")).thenReturn(context);
when(compatSkillLookupService.canAccess(cjkSkill, null, Map.of())).thenReturn(true);
String location = service.downloadLocationByQuery("需求", "20260707.025847", null, null);
assertThat(location)
.isEqualTo("/api/v1/skills/global/%E9%9C%80%E6%B1%82/versions/20260707.025847/download");
// The header value must be writable as ISO-8859-1 (all bytes in 0-255).
assertThat(java.nio.charset.StandardCharsets.ISO_8859_1.newEncoder().canEncode(location)).isTrue();
}
@Test
void downloadLocationByQuery_includesVersionSegmentForAsciiSlug() {
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
Skill publicSkill = new Skill(1L, "my-skill", "owner-1", SkillVisibility.PUBLIC);
CompatSkillLookupService.CompatSkillContext context = new CompatSkillLookupService.CompatSkillContext(
namespace,
publicSkill,
Optional.empty()
);
when(compatSkillLookupService.findByLegacySlug("my-skill")).thenReturn(context);
when(compatSkillLookupService.canAccess(publicSkill, null, Map.of())).thenReturn(true);
String location = service.downloadLocationByQuery("my-skill", "20260707.025847", null, null);
assertThat(location).isEqualTo("/api/v1/skills/team-a/my-skill/versions/20260707.025847/download");
}
}