marketplace.go
1 package skills 2 3 import ( 4 "archive/zip" 5 "bytes" 6 "context" 7 "encoding/json" 8 "errors" 9 "fmt" 10 "io" 11 "io/fs" 12 "net/http" 13 "os" 14 "os/exec" 15 "path/filepath" 16 "sort" 17 "strings" 18 "sync" 19 "time" 20 ) 21 22 // marketplaceDownloadClient is the HTTP client used for zip-transport 23 // installs. Separate from the MarketplaceClient's registry client so 24 // downloads can tolerate slower upstream responses, but still has a 25 // 2-minute ceiling as a safety floor if the caller's context has no 26 // deadline. 27 var marketplaceDownloadClient = &http.Client{Timeout: 2 * time.Minute} 28 29 // runGitCtx is a context-aware variant of runGit from api.go. Lets 30 // InstallFromMarketplace cancel in-flight clones when the request 31 // context is canceled. 32 func runGitCtx(ctx context.Context, dir string, args ...string) error { 33 cmd := exec.CommandContext(ctx, "git", args...) 34 cmd.Dir = dir 35 out, err := cmd.CombinedOutput() 36 if err != nil { 37 return fmt.Errorf("%s: %s", err, strings.TrimSpace(string(out))) 38 } 39 return nil 40 } 41 42 // RegistryIndex is the top-level JSON document served by the marketplace 43 // registry repo. Field names match the schema in 44 // docs/superpowers/specs/2026-04-06-skill-marketplace-design.md. 45 type RegistryIndex struct { 46 Version int `json:"version"` 47 UpdatedAt string `json:"updated_at"` 48 Skills []MarketplaceEntry `json:"skills"` 49 } 50 51 // MarketplaceEntry is one skill listing in the registry. 52 // 53 // Transport: either Repo (git clone) or DownloadURL (HTTP zip) must be set. 54 // The git path is the primary transport for skills that have a public 55 // source repository. DownloadURL is the fallback for ClawHub skills that 56 // exist only as zip artifacts served by ClawHub's Convex backend — there 57 // is no GitHub repo to clone, so we fetch the zip and extract it 58 // directly into the skills directory. 59 type MarketplaceEntry struct { 60 Slug string `json:"slug"` 61 Name string `json:"name"` 62 Description string `json:"description"` 63 Author string `json:"author"` 64 License string `json:"license,omitempty"` 65 Repo string `json:"repo,omitempty"` 66 RepoPath string `json:"repo_path,omitempty"` 67 Ref string `json:"ref,omitempty"` 68 DownloadURL string `json:"download_url,omitempty"` 69 Homepage string `json:"homepage,omitempty"` 70 Downloads int `json:"downloads,omitempty"` 71 Stars int `json:"stars,omitempty"` 72 Version string `json:"version,omitempty"` 73 Security SecurityScan `json:"security,omitempty"` 74 Tags []string `json:"tags,omitempty"` 75 } 76 77 // SecurityScan mirrors the scan results published by ClawHub. 78 // Empty strings mean "not scanned" and render as a neutral badge. 79 type SecurityScan struct { 80 VirusTotal string `json:"virustotal,omitempty"` 81 OpenClaw string `json:"openclaw,omitempty"` 82 ScannedAt string `json:"scanned_at,omitempty"` 83 } 84 85 // IsMalicious returns true when any scanner flagged the entry as malicious. 86 // Used as a server-side gate in both the list and install endpoints. 87 func (e MarketplaceEntry) IsMalicious() bool { 88 return e.Security.VirusTotal == "malicious" || e.Security.OpenClaw == "malicious" 89 } 90 91 // defaultStaleCooldown is the minimum gap between upstream refetch 92 // attempts once we've started serving stale. Without it, every Load 93 // during a registry outage would re-hit the remote and turn normal UI 94 // traffic into a retry storm. 95 const defaultStaleCooldown = 1 * time.Minute 96 97 // MarketplaceClient fetches and caches the registry index. 98 // 99 // Caching rules (see design doc §Registry Cache): 100 // - First fetch populates the in-memory cache. 101 // - Subsequent calls within TTL return the cached copy. 102 // - After TTL expires, the next call refetches; on fetch failure the 103 // previous cache is served as stale (IsStale() returns true) and a 104 // retry cooldown is set so further Loads keep serving stale without 105 // hammering the upstream. 106 // - If no cache exists and fetch fails, Load returns the error. 107 type MarketplaceClient struct { 108 url string 109 ttl time.Duration 110 http *http.Client 111 112 // staleCooldown bounds how often we re-attempt an upstream fetch 113 // while in stale mode. Exposed as a field (not a constructor arg) 114 // so tests can set a short cooldown directly. 115 staleCooldown time.Duration 116 117 mu sync.Mutex 118 cache *RegistryIndex 119 fetched time.Time 120 stale bool 121 retryAfter time.Time 122 } 123 124 // NewMarketplaceClient constructs a client with the given registry URL and 125 // cache TTL. A TTL of 0 forces every call to refetch (used by stale-on-error 126 // tests and by operators who explicitly disable caching). 127 func NewMarketplaceClient(url string, ttl time.Duration) *MarketplaceClient { 128 return &MarketplaceClient{ 129 url: url, 130 ttl: ttl, 131 http: &http.Client{Timeout: 15 * time.Second}, 132 staleCooldown: defaultStaleCooldown, 133 } 134 } 135 136 // Load returns the current registry index, refetching when the cache is 137 // empty or past TTL. See the type doc for stale-on-error semantics. 138 func (c *MarketplaceClient) Load(ctx context.Context) (*RegistryIndex, error) { 139 c.mu.Lock() 140 defer c.mu.Unlock() 141 142 // Fresh cache → return immediately. 143 if c.cache != nil && time.Since(c.fetched) < c.ttl { 144 c.stale = false 145 return c.cache, nil 146 } 147 148 // Stale-mode cooldown in effect → keep serving stale without 149 // re-attempting the upstream fetch. Prevents retry storms during 150 // registry outages. 151 if c.cache != nil && !c.retryAfter.IsZero() && time.Now().Before(c.retryAfter) { 152 c.stale = true 153 return c.cache, nil 154 } 155 156 idx, err := c.fetch(ctx) 157 if err != nil { 158 if c.cache != nil { 159 c.stale = true 160 c.retryAfter = time.Now().Add(c.staleCooldown) 161 return c.cache, nil 162 } 163 return nil, err 164 } 165 166 c.cache = idx 167 c.fetched = time.Now() 168 c.stale = false 169 c.retryAfter = time.Time{} 170 return c.cache, nil 171 } 172 173 // IsStale reports whether the most recent Load served a stale cache because 174 // the upstream fetch failed. Handlers use this to set an X-Cache-Stale header. 175 func (c *MarketplaceClient) IsStale() bool { 176 c.mu.Lock() 177 defer c.mu.Unlock() 178 return c.stale 179 } 180 181 // Sentinel errors so daemon handlers can map to exact HTTP statuses without 182 // parsing message strings. 183 var ( 184 ErrSkillAlreadyInstalled = errors.New("skill already installed") 185 ErrMaliciousSkill = errors.New("skill blocked by security scan") 186 ErrInvalidSkillPayload = errors.New("invalid skill payload") 187 ErrMarketplaceUpstreamFailure = errors.New("marketplace upstream failure") 188 ) 189 190 // InstallFromMarketplace runs the full install flow for a marketplace entry. 191 // Dispatches to the git transport (clone → stage) when entry.Repo is set, 192 // or the zip transport (HTTP GET → extract) when entry.DownloadURL is set. 193 // Both paths share the same validation rules, slug lock, sentinel errors, 194 // and cleanup guarantees. 195 // 196 // ctx is propagated into the transport layer: git clone runs under 197 // exec.CommandContext, zip downloads run under an http.Request with the 198 // same context. Cancellation aborts the in-flight operation and cleans 199 // up staging dirs on the way out. 200 // 201 // Steps common to both transports: 202 // 1. Validate slug. 203 // 2. Security gate (malicious → ErrMaliciousSkill). 204 // 3. Per-slug lock (serializes concurrent installs for the same slug). 205 // 4. Already-installed check (→ ErrSkillAlreadyInstalled). 206 // 5. Transport-specific payload acquisition into a stage directory. 207 // 6. Verify SKILL.md exists and parses; verify frontmatter name == slug. 208 // 7. Atomic rename stage → ~/.shannon/skills/<slug>/. 209 // 210 // All failures clean up temp directories. No partial installs ever remain. 211 func InstallFromMarketplace(ctx context.Context, shannonDir string, entry MarketplaceEntry, locks *SlugLocks) error { 212 if err := ValidateSkillName(entry.Slug); err != nil { 213 return err 214 } 215 if entry.IsMalicious() { 216 return ErrMaliciousSkill 217 } 218 if entry.Repo == "" && entry.DownloadURL == "" { 219 return fmt.Errorf("%w: entry has no transport (need repo or download_url)", ErrInvalidSkillPayload) 220 } 221 222 unlock := locks.Lock(entry.Slug) 223 defer unlock() 224 225 destDir := filepath.Join(shannonDir, "skills", entry.Slug) 226 if _, err := os.Stat(filepath.Join(destDir, "SKILL.md")); err == nil { 227 return ErrSkillAlreadyInstalled 228 } 229 230 tmpRoot := filepath.Join(shannonDir, "tmp") 231 if err := os.MkdirAll(tmpRoot, 0700); err != nil { 232 return fmt.Errorf("create tmp root: %w", err) 233 } 234 235 stageDir, err := os.MkdirTemp(tmpRoot, "skill-stage-"+entry.Slug+"-*") 236 if err != nil { 237 return fmt.Errorf("create stage dir: %w", err) 238 } 239 // stageDir is removed on failure; on success we rename it away so 240 // the RemoveAll is a no-op. 241 defer os.RemoveAll(stageDir) 242 243 // Transport dispatch: git path clones via exec.CommandContext so 244 // cancellation aborts in-flight clones; zip path passes ctx to the 245 // http.Request so cancellation aborts in-flight downloads. 246 if entry.Repo != "" { 247 if err := installFromGit(ctx, entry, stageDir, tmpRoot); err != nil { 248 return err 249 } 250 } else { 251 // Remove the empty stageDir MkdirTemp created; extractZipToSkill 252 // recreates it inside its own cleanup guarantees. 253 os.RemoveAll(stageDir) 254 if err := installFromZip(ctx, entry, stageDir); err != nil { 255 return err 256 } 257 } 258 259 // Verify SKILL.md exists and matches the declared slug. Same rules 260 // apply regardless of transport. 261 skillFile := filepath.Join(stageDir, "SKILL.md") 262 if _, err := os.Stat(skillFile); err != nil { 263 return fmt.Errorf("%w: SKILL.md missing at stage dir", ErrInvalidSkillPayload) 264 } 265 // loadSkillMD passes dirName=entry.Slug and enforces that the zip's 266 // canonical identity (frontmatter `slug` when present, else `name`) 267 // matches — so a separate `parsed.Name != entry.Slug` check here would 268 // just duplicate that invariant. 269 if _, err := loadSkillMD(skillFile, entry.Slug, "marketplace"); err != nil { 270 return fmt.Errorf("%w: %v", ErrInvalidSkillPayload, err) 271 } 272 if err := writeMarketplaceProvenance(stageDir, entry.Slug); err != nil { 273 return fmt.Errorf("write marketplace provenance: %w", err) 274 } 275 276 // Atomic rename into place. 277 if err := os.MkdirAll(filepath.Dir(destDir), 0700); err != nil { 278 return fmt.Errorf("create skills dir: %w", err) 279 } 280 if err := os.Rename(stageDir, destDir); err != nil { 281 return fmt.Errorf("install rename: %w", err) 282 } 283 284 return nil 285 } 286 287 // installFromGit clones entry.Repo into a temp dir, selects the right 288 // subtree (entry.RepoPath or the clone root), and stages a clean copy 289 // into stageDir. Git subprocesses run under ctx so cancellation 290 // propagates. Payload-level validation errors (symlink, walk failure) 291 // are wrapped as ErrInvalidSkillPayload so the handler maps them to 292 // 422, matching the design doc's error matrix. 293 func installFromGit(ctx context.Context, entry MarketplaceEntry, stageDir, tmpRoot string) error { 294 cloneDir, err := os.MkdirTemp(tmpRoot, "skill-clone-"+entry.Slug+"-*") 295 if err != nil { 296 return fmt.Errorf("create clone dir: %w", err) 297 } 298 defer os.RemoveAll(cloneDir) 299 300 ref := entry.Ref 301 if ref == "" { 302 ref = "main" 303 } 304 if entry.RepoPath == "" { 305 if err := runGitCtx(ctx, cloneDir, "clone", "--depth=1", "--branch", ref, entry.Repo, "."); err != nil { 306 return fmt.Errorf("%w: git clone: %v", ErrMarketplaceUpstreamFailure, err) 307 } 308 } else { 309 if err := runGitCtx(ctx, cloneDir, "clone", "--depth=1", "--filter=blob:none", "--sparse", "--branch", ref, entry.Repo, "."); err != nil { 310 return fmt.Errorf("%w: git clone: %v", ErrMarketplaceUpstreamFailure, err) 311 } 312 if err := runGitCtx(ctx, cloneDir, "sparse-checkout", "set", entry.RepoPath); err != nil { 313 return fmt.Errorf("%w: git sparse-checkout: %v", ErrMarketplaceUpstreamFailure, err) 314 } 315 } 316 317 srcDir := cloneDir 318 if entry.RepoPath != "" { 319 srcDir = filepath.Join(cloneDir, entry.RepoPath) 320 } 321 322 // Remove the empty stageDir MkdirTemp created before calling this 323 // function; stageCleanPayload recreates it. 324 os.RemoveAll(stageDir) 325 if err := stageCleanPayload(srcDir, stageDir); err != nil { 326 // Payload-level failures (symlinks, walk errors) are client- 327 // visible invalid payloads, not upstream or internal errors. 328 return fmt.Errorf("%w: %v", ErrInvalidSkillPayload, err) 329 } 330 return nil 331 } 332 333 // installFromZip fetches entry.DownloadURL and extracts it into stageDir 334 // via extractZipToSkill. HTTP failures surface as 335 // ErrMarketplaceUpstreamFailure so the handler maps to 502. Uses the 336 // caller's ctx directly so client disconnect or daemon shutdown aborts 337 // the in-flight download. marketplaceDownloadClient provides a 2-minute 338 // safety ceiling when ctx has no deadline. 339 func installFromZip(ctx context.Context, entry MarketplaceEntry, stageDir string) error { 340 req, err := http.NewRequestWithContext(ctx, "GET", entry.DownloadURL, nil) 341 if err != nil { 342 return fmt.Errorf("%w: build download request: %v", ErrMarketplaceUpstreamFailure, err) 343 } 344 resp, err := marketplaceDownloadClient.Do(req) 345 if err != nil { 346 return fmt.Errorf("%w: download: %v", ErrMarketplaceUpstreamFailure, err) 347 } 348 defer resp.Body.Close() 349 if resp.StatusCode < 200 || resp.StatusCode >= 300 { 350 return fmt.Errorf("%w: download status %d", ErrMarketplaceUpstreamFailure, resp.StatusCode) 351 } 352 353 if err := extractZipToSkill(resp.Body, stageDir); err != nil { 354 // Payload-level failures (symlink, zip-slip, zip-bomb, bad 355 // archive) are client-visible invalid payloads. Mapped to 422. 356 return fmt.Errorf("%w: %v", ErrInvalidSkillPayload, err) 357 } 358 return nil 359 } 360 361 // Caps for zip-based skill installs. 50 MB is more than generous for 362 // any realistic skill (ontology was 12 KB); 200 MB uncompressed guards 363 // against zip bombs. Variables (not consts) so tests can set a small 364 // cap to exercise the guard without allocating 200 MB of in-memory 365 // content. 366 var ( 367 maxZipCompressedBytes int64 = 50 * 1024 * 1024 368 maxZipUncompressedBytes int64 = 200 * 1024 * 1024 369 ) 370 371 // extractZipToSkill reads a zip archive from body and extracts it into 372 // destDir, applying the same exclusion, symlink rejection, and mode 373 // preservation rules as stageCleanPayload. It is the zip-transport 374 // equivalent of (git clone + stageCleanPayload) collapsed into one 375 // step because a zip archive is already a self-contained payload. 376 // 377 // Rejections (all with destDir cleanup): 378 // - Compressed body > maxZipCompressedBytes 379 // - Sum of uncompressed entry sizes > maxZipUncompressedBytes (zip bomb guard) 380 // - Any entry with a symlink mode bit 381 // - Any entry whose cleaned path escapes destDir (zip-slip) 382 // - Any entry whose first path segment is excluded git metadata 383 func extractZipToSkill(body io.Reader, destDir string) error { 384 // Read the entire compressed payload into memory through a hard cap. 385 // archive/zip requires a ReaderAt, so we buffer the body. 386 limited := io.LimitReader(body, maxZipCompressedBytes+1) 387 raw, err := io.ReadAll(limited) 388 if err != nil { 389 return fmt.Errorf("read zip body: %w", err) 390 } 391 if int64(len(raw)) > maxZipCompressedBytes { 392 return fmt.Errorf("zip payload exceeds %d bytes", maxZipCompressedBytes) 393 } 394 395 zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) 396 if err != nil { 397 return fmt.Errorf("parse zip: %w", err) 398 } 399 400 excluded := map[string]bool{ 401 ".git": true, 402 ".github": true, 403 ".gitignore": true, 404 ".gitattributes": true, 405 } 406 407 if err := os.MkdirAll(destDir, 0700); err != nil { 408 return fmt.Errorf("create dest dir: %w", err) 409 } 410 411 // All work happens inside a closure so any failure triggers cleanup 412 // via the single RemoveAll below. 413 extractErr := func() error { 414 absDest, err := filepath.Abs(destDir) 415 if err != nil { 416 return fmt.Errorf("resolve dest dir: %w", err) 417 } 418 419 // Zip-bomb guard: bound the TOTAL actual bytes decompressed 420 // across all entries, using a LimitReader that counts real 421 // bytes read — not the attacker-controlled UncompressedSize64 422 // in the zip header. This prevents a malicious archive from 423 // declaring 0-byte entries and then streaming gigabytes into 424 // memory via ReadAll. 425 remaining := maxZipUncompressedBytes 426 427 for _, f := range zr.File { 428 // Symlink rejection. 429 if f.Mode()&os.ModeSymlink != 0 { 430 return fmt.Errorf("unsupported symlink in skill payload: %s", f.Name) 431 } 432 433 // Clean the path and verify it stays within destDir. 434 // filepath.Clean normalizes ../ which would otherwise escape. 435 cleanRel := filepath.Clean(f.Name) 436 if cleanRel == "." || cleanRel == "" { 437 continue 438 } 439 destPath := filepath.Join(absDest, cleanRel) 440 absPath, err := filepath.Abs(destPath) 441 if err != nil { 442 return fmt.Errorf("resolve entry path %q: %w", f.Name, err) 443 } 444 if absPath != absDest && !strings.HasPrefix(absPath, absDest+string(filepath.Separator)) { 445 return fmt.Errorf("zip entry %q escapes dest dir", f.Name) 446 } 447 448 // Exclusion check: any path segment matches. 449 segments := strings.Split(cleanRel, string(filepath.Separator)) 450 skip := false 451 for _, seg := range segments { 452 if excluded[seg] { 453 skip = true 454 break 455 } 456 } 457 if skip { 458 continue 459 } 460 461 if f.FileInfo().IsDir() { 462 if err := os.MkdirAll(destPath, 0700); err != nil { 463 return fmt.Errorf("mkdir %q: %w", destPath, err) 464 } 465 continue 466 } 467 468 // Ensure parent exists (zip entries may list files before dirs). 469 if err := os.MkdirAll(filepath.Dir(destPath), 0700); err != nil { 470 return fmt.Errorf("mkdir parent of %q: %w", destPath, err) 471 } 472 473 // Read with a per-entry budget of (remaining+1) bytes. 474 // If we can read even 1 byte past the budget, the archive 475 // exceeds the uncompressed cap. This tracks ACTUAL bytes 476 // decompressed, not declared size. 477 rc, err := f.Open() 478 if err != nil { 479 return fmt.Errorf("open zip entry %q: %w", f.Name, err) 480 } 481 content, err := io.ReadAll(io.LimitReader(rc, remaining+1)) 482 rc.Close() 483 if err != nil { 484 return fmt.Errorf("read zip entry %q: %w", f.Name, err) 485 } 486 if int64(len(content)) > remaining { 487 return fmt.Errorf("zip uncompressed size exceeds %d bytes", maxZipUncompressedBytes) 488 } 489 remaining -= int64(len(content)) 490 491 srcMode := f.Mode().Perm() & 0755 492 if srcMode&0400 == 0 { 493 srcMode |= 0400 494 } 495 if err := os.WriteFile(destPath, content, srcMode); err != nil { 496 return fmt.Errorf("write %q: %w", destPath, err) 497 } 498 if err := os.Chmod(destPath, srcMode); err != nil { 499 return fmt.Errorf("chmod %q: %w", destPath, err) 500 } 501 } 502 return nil 503 }() 504 505 if extractErr != nil { 506 os.RemoveAll(destDir) 507 return extractErr 508 } 509 return nil 510 } 511 512 // stageCleanPayload walks src and copies every regular file into dst, 513 // excluding git metadata (.git/, .github/, .gitignore, .gitattributes) at 514 // any depth. Symlinks are rejected unconditionally: if the walk encounters 515 // one, the function removes dst (cleaning up any partial copy) and returns 516 // a 422-worthy error. See design doc §Install flow step 9. 517 // 518 // Exclusions match on the base name of any path segment, so nested .git dirs 519 // are also skipped. 520 // 521 // File modes are preserved from the source (masked to 0755 to strip any 522 // setuid/setgid/sticky bits), so shipped helper scripts keep their 523 // executable bit — this matters for community skills like 524 // self-improving-agent that ship scripts/activator.sh. 525 func stageCleanPayload(src, dst string) error { 526 excluded := map[string]bool{ 527 ".git": true, 528 ".github": true, 529 ".gitignore": true, 530 ".gitattributes": true, 531 } 532 533 if err := os.MkdirAll(dst, 0700); err != nil { 534 return fmt.Errorf("create stage dir: %w", err) 535 } 536 537 walkErr := filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { 538 if err != nil { 539 return err 540 } 541 if path == src { 542 return nil 543 } 544 545 // Reject symlinks outright. WalkDir gives us the lstat'd entry via 546 // d.Type(), which preserves the Symlink bit. 547 if d.Type()&os.ModeSymlink != 0 { 548 return fmt.Errorf("unsupported symlink in skill payload: %s", path) 549 } 550 551 rel, err := filepath.Rel(src, path) 552 if err != nil { 553 return err 554 } 555 556 // Exclude if any path segment matches. 557 for _, seg := range strings.Split(rel, string(filepath.Separator)) { 558 if excluded[seg] { 559 if d.IsDir() { 560 return filepath.SkipDir 561 } 562 return nil 563 } 564 } 565 566 destPath := filepath.Join(dst, rel) 567 if d.IsDir() { 568 return os.MkdirAll(destPath, 0700) 569 } 570 571 // Preserve source file mode so shipped helper scripts keep their 572 // executable bit. Mask to 0755 so no file can become setuid/setgid/ 573 // sticky via install, and ensure owner-read is always set. 574 info, err := os.Lstat(path) 575 if err != nil { 576 return err 577 } 578 srcMode := info.Mode().Perm() & 0755 579 if srcMode&0400 == 0 { 580 srcMode |= 0400 581 } 582 583 content, err := os.ReadFile(path) 584 if err != nil { 585 return err 586 } 587 if err := os.WriteFile(destPath, content, srcMode); err != nil { 588 return err 589 } 590 // os.WriteFile respects the umask on some platforms; chmod to 591 // guarantee the requested mode lands on disk. 592 return os.Chmod(destPath, srcMode) 593 }) 594 595 if walkErr != nil { 596 os.RemoveAll(dst) 597 return walkErr 598 } 599 return nil 600 } 601 602 // SlugLocks is a map of per-slug mutexes. The outer mutex protects map access; 603 // each per-slug lock serializes install/uninstall/usage-check operations for 604 // that slug only. Different slugs never block each other. 605 // 606 // Usage: 607 // 608 // unlock := locks.Lock("my-skill") 609 // defer unlock() 610 type SlugLocks struct { 611 outer sync.Mutex 612 perSlug map[string]*sync.Mutex 613 } 614 615 // NewSlugLocks creates an empty SlugLocks. 616 func NewSlugLocks() *SlugLocks { 617 return &SlugLocks{perSlug: make(map[string]*sync.Mutex)} 618 } 619 620 // Lock acquires the per-slug mutex and returns a function that releases it. 621 // The returned function is safe to call exactly once (typically via defer). 622 func (l *SlugLocks) Lock(slug string) func() { 623 l.outer.Lock() 624 m, ok := l.perSlug[slug] 625 if !ok { 626 m = &sync.Mutex{} 627 l.perSlug[slug] = m 628 } 629 l.outer.Unlock() 630 631 m.Lock() 632 return m.Unlock 633 } 634 635 // FilterSortPaginate applies the marketplace list pipeline to a raw index slice. 636 // Returns the page slice plus the total count of entries that matched the 637 // filter (used by the API response for client-side pagination controls). 638 // 639 // Pipeline: 640 // 1. Drop malicious entries (server-side security gate). 641 // 2. Apply case-insensitive substring search against name+description+author. 642 // 3. Sort by the requested key (downloads|stars|name); unknown keys fall 643 // back to downloads desc. 644 // 4. Slice to the requested page. Out-of-range pages return an empty slice. 645 // 646 // Sort keys: 647 // - "downloads" (default): descending by Downloads, ties broken by name asc 648 // - "stars": descending by Stars, ties broken by name asc 649 // - "name": ascending by Name 650 func FilterSortPaginate(entries []MarketplaceEntry, query, sortKey string, page, size int) ([]MarketplaceEntry, int) { 651 if page < 1 { 652 page = 1 653 } 654 if size < 1 { 655 size = 20 656 } 657 if size > 100 { 658 size = 100 659 } 660 661 // Step 1+2: filter. 662 q := strings.ToLower(strings.TrimSpace(query)) 663 filtered := make([]MarketplaceEntry, 0, len(entries)) 664 for _, e := range entries { 665 if e.IsMalicious() { 666 continue 667 } 668 if q != "" { 669 hay := strings.ToLower(e.Name + " " + e.Description + " " + e.Author) 670 if !strings.Contains(hay, q) { 671 continue 672 } 673 } 674 filtered = append(filtered, e) 675 } 676 677 // Step 3: sort. 678 switch sortKey { 679 case "name": 680 sort.SliceStable(filtered, func(i, j int) bool { 681 return filtered[i].Name < filtered[j].Name 682 }) 683 case "stars": 684 sort.SliceStable(filtered, func(i, j int) bool { 685 if filtered[i].Stars != filtered[j].Stars { 686 return filtered[i].Stars > filtered[j].Stars 687 } 688 return filtered[i].Name < filtered[j].Name 689 }) 690 default: // "downloads" and unknown 691 sort.SliceStable(filtered, func(i, j int) bool { 692 if filtered[i].Downloads != filtered[j].Downloads { 693 return filtered[i].Downloads > filtered[j].Downloads 694 } 695 return filtered[i].Name < filtered[j].Name 696 }) 697 } 698 699 total := len(filtered) 700 701 // Step 4: paginate. 702 start := (page - 1) * size 703 if start >= total { 704 return []MarketplaceEntry{}, total 705 } 706 end := start + size 707 if end > total { 708 end = total 709 } 710 return filtered[start:end], total 711 } 712 713 func (c *MarketplaceClient) fetch(ctx context.Context) (*RegistryIndex, error) { 714 req, err := http.NewRequestWithContext(ctx, "GET", c.url, nil) 715 if err != nil { 716 return nil, fmt.Errorf("build request: %w", err) 717 } 718 resp, err := c.http.Do(req) 719 if err != nil { 720 return nil, fmt.Errorf("fetch registry: %w", err) 721 } 722 defer resp.Body.Close() 723 if resp.StatusCode < 200 || resp.StatusCode >= 300 { 724 return nil, fmt.Errorf("fetch registry: status %d", resp.StatusCode) 725 } 726 body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) // 10 MB cap 727 if err != nil { 728 return nil, fmt.Errorf("read registry body: %w", err) 729 } 730 var idx RegistryIndex 731 if err := json.Unmarshal(body, &idx); err != nil { 732 return nil, fmt.Errorf("parse registry: %w", err) 733 } 734 return &idx, nil 735 }