1use sqlx::SqlitePool;
14
15use crate::context::rule_render::{RuleRenderInput, render_code_spec};
16use crate::context::rule_source::RuleExample;
17use crate::errors::CoreError;
18use crate::observability::privacy::{redact_secretish_tokens, strip_private_tagged_regions};
19use crate::packs::manifest::{PackManifest, PackRule};
20use crate::packs::{
21 PACK_CONFIDENCE, PACK_ORIGIN, pack_rule_tag, pack_source_repo, pack_version_tag,
22};
23
24const PACK_SKILL_SOURCE: &str = "pack";
28
29fn sanitize(input: &str) -> String {
33 redact_secretish_tokens(&strip_private_tagged_regions(input))
34}
35
36fn slugify(value: &str) -> String {
40 value
41 .to_lowercase()
42 .chars()
43 .map(|c| {
44 if c.is_ascii_alphanumeric() || c == '_' {
45 c
46 } else {
47 '-'
48 }
49 })
50 .collect::<String>()
51 .split('-')
52 .filter(|s| !s.is_empty())
53 .collect::<Vec<_>>()
54 .join("-")
55}
56
57fn deterministic_suffix(pack_rule_id: &str) -> String {
62 use std::fmt::Write as _;
63
64 use sha2::{Digest, Sha256};
65 let mut hasher = Sha256::new();
66 hasher.update(pack_rule_id.as_bytes());
67 let digest = hasher.finalize();
68 digest.iter().take(4).fold(String::new(), |mut acc, b| {
69 let _ = write!(acc, "{b:02x}");
70 acc
71 })
72}
73
74fn local_skill_id(pack_id: &str, pack_rule_id: &str) -> String {
76 let pack_slug = slugify(pack_id);
81 let rule_leaf = pack_rule_id.rsplit('/').next().unwrap_or(pack_rule_id);
82 let rule_slug = slugify(rule_leaf);
83 format!(
84 "pack-{pack_slug}-{rule_slug}-{}",
85 deterministic_suffix(pack_rule_id)
86 )
87}
88
89fn effective_globs(rule: &PackRule, manifest: &PackManifest) -> Vec<String> {
92 let mut globs: Vec<String> = if rule.file_globs.is_empty() {
93 manifest
94 .target
95 .as_ref()
96 .map(|t| t.file_globs.clone())
97 .unwrap_or_default()
98 } else {
99 rule.file_globs.clone()
100 };
101 globs.retain(|g| !g.trim().is_empty());
102 globs
103}
104
105fn build_tags(rule: &PackRule, manifest: &PackManifest) -> Vec<String> {
109 let mut tags: Vec<String> = Vec::new();
110 tags.push(PACK_ORIGIN.to_owned());
111 tags.push(pack_version_tag(&manifest.id, &manifest.version));
112 tags.push(pack_rule_tag(&rule.id));
113
114 if let Some(lang) = manifest
115 .target
116 .as_ref()
117 .and_then(|t| t.languages.first())
118 .map(|l| l.trim().to_ascii_lowercase())
119 .filter(|l| !l.is_empty())
120 {
121 tags.push(lang);
122 }
123
124 if let Some(sev) = rule
125 .severity
126 .as_deref()
127 .map(|s| s.trim().to_ascii_lowercase())
128 .filter(|s| !s.is_empty())
129 {
130 tags.push(format!("severity:{sev}"));
131 }
132
133 for tag in &rule.tags {
134 let trimmed = tag.trim();
135 if !trimmed.is_empty() {
136 tags.push(trimmed.to_owned());
137 }
138 }
139
140 let mut seen = std::collections::HashSet::new();
142 tags.retain(|t| seen.insert(t.clone()));
143 tags
144}
145
146fn render_body(
152 skill_id: &str,
153 rule: &PackRule,
154 manifest: &PackManifest,
155 globs: &[String],
156 example: Option<&RuleExample>,
157) -> String {
158 let source_repo = pack_source_repo(&manifest.id);
159 let description = rule
160 .body
161 .as_deref()
162 .map(str::trim)
163 .filter(|b| !b.is_empty())
164 .map(sanitize)
165 .unwrap_or_default();
166 let examples_slice = example.map(std::slice::from_ref);
167 let input = RuleRenderInput {
168 id: skill_id,
169 name: rule.title.trim(),
170 r#type: "review_standard",
171 confidence: PACK_CONFIDENCE,
172 origin: PACK_ORIGIN,
173 source_repo: Some(source_repo.as_str()),
174 file_patterns: globs,
175 description: &description,
176 trigger: None,
179 check_prompt: None,
180 examples: examples_slice,
181 };
182 render_code_spec(&input)
183}
184
185fn build_example(skill_id: &str, rule: &PackRule) -> Option<RuleExample> {
189 let ex = rule.examples.as_ref()?;
190 let bad = sanitize(ex.bad.as_deref().unwrap_or_default());
191 let good = sanitize(ex.good.as_deref().unwrap_or_default());
192 if bad.trim().is_empty() || good.trim().is_empty() {
193 return None;
194 }
195 let description = ex
196 .description
197 .as_deref()
198 .map(sanitize)
199 .map(|d| d.trim().to_owned())
200 .filter(|d| !d.is_empty());
201 Some(RuleExample {
202 id: format!(
203 "example-pack-{}",
204 crate::packs::manifest::manifest_sha256(skill_id.as_bytes())
205 ),
206 skill_id: skill_id.to_owned(),
207 bad_code: bad.trim().to_owned(),
208 good_code: good.trim().to_owned(),
209 description,
210 source: PACK_ORIGIN.to_owned(),
211 })
212}
213
214fn build_skill_md(rule: &PackRule, tags: &[String], body: &str) -> String {
218 let mut md = String::new();
219 md.push_str("---\n");
220 md.push_str("type: review_standard\n");
221 md.push_str("engines: [claude]\n");
222 md.push_str(&format!("tags: [{}]\n", tags.join(", ")));
223 md.push_str("origin: pack\n");
224 md.push_str("---\n\n");
225 md.push_str(&format!("# {}\n\n", rule.title.trim()));
226 md.push_str(body);
227 md.push('\n');
228 md
229}
230
231#[derive(Debug, Clone)]
235pub struct InstalledPackRule {
236 pub skill_id: String,
237 pub pack_rule_id: String,
238 pub title: String,
239 pub file_patterns: Vec<String>,
240 pub tags: Vec<String>,
241 pub origin: String,
242 pub source_repo: String,
243 pub confidence: f64,
244 pub has_example: bool,
245}
246
247#[derive(Debug, Clone)]
249pub struct InstallPackOutcome {
250 pub pack_id: String,
251 pub pack_version: String,
252 pub rules: Vec<InstalledPackRule>,
254 pub superseded_rule_ids: Vec<String>,
258 pub dry_run: bool,
261}
262
263pub async fn install_pack(
272 db: &SqlitePool,
273 manifest: &PackManifest,
274 dry_run: bool,
275) -> Result<InstallPackOutcome, CoreError> {
276 let source_repo = pack_source_repo(&manifest.id);
277
278 struct Prepared {
281 skill_id: String,
282 pack_rule_id: String,
283 title: String,
284 globs: Vec<String>,
285 tags: Vec<String>,
286 body: String,
287 skill_md: String,
288 example: Option<RuleExample>,
289 }
290 let mut prepared: Vec<Prepared> = Vec::with_capacity(manifest.rules.len());
291 for rule in &manifest.rules {
292 if rule.title.trim().is_empty() {
293 return Err(CoreError::Validation(format!(
294 "pack '{}' has a rule with an empty title (id '{}')",
295 manifest.id, rule.id
296 )));
297 }
298 let skill_id = local_skill_id(&manifest.id, &rule.id);
299 let globs = effective_globs(rule, manifest);
300 let tags = build_tags(rule, manifest);
301 let example = build_example(&skill_id, rule);
302 let body = render_body(&skill_id, rule, manifest, &globs, example.as_ref());
303 let skill_md = build_skill_md(rule, &tags, &body);
304 prepared.push(Prepared {
305 skill_id,
306 pack_rule_id: rule.id.clone(),
307 title: rule.title.trim().to_owned(),
308 globs,
309 tags,
310 body,
311 skill_md,
312 example,
313 });
314 }
315
316 let installed: Vec<InstalledPackRule> = prepared
317 .iter()
318 .map(|p| InstalledPackRule {
319 skill_id: p.skill_id.clone(),
320 pack_rule_id: p.pack_rule_id.clone(),
321 title: p.title.clone(),
322 file_patterns: p.globs.clone(),
323 tags: p.tags.clone(),
324 origin: PACK_ORIGIN.to_owned(),
325 source_repo: source_repo.clone(),
326 confidence: PACK_CONFIDENCE,
327 has_example: p.example.is_some(),
328 })
329 .collect();
330
331 if dry_run {
332 return Ok(InstallPackOutcome {
333 pack_id: manifest.id.clone(),
334 pack_version: manifest.version.clone(),
335 rules: installed,
336 superseded_rule_ids: Vec::new(),
337 dry_run: true,
338 });
339 }
340
341 let base_dir = crate::skill_fs::skills_base_dir()
343 .map_err(CoreError::Internal)?
344 .join(PACK_SKILL_SOURCE);
345 std::fs::create_dir_all(&base_dir)
346 .map_err(|e| CoreError::Internal(format!("failed to create pack skills dir: {e}")))?;
347 let canonical_base = base_dir
348 .canonicalize()
349 .map_err(|e| CoreError::Internal(format!("failed to resolve pack skills dir: {e}")))?;
350
351 let now_utc = chrono::Utc::now();
352 let now = now_utc.format("%Y-%m-%d %H:%M:%S").to_string();
353 let now_ms: i64 = now_utc.timestamp_millis();
354
355 let mut tx = db.begin().await?;
356 let mut superseded_rule_ids: Vec<String> = Vec::new();
357 let mut written_dirs: Vec<std::path::PathBuf> = Vec::new();
358
359 for p in &prepared {
360 let version_tag = pack_version_tag(&manifest.id, &manifest.version);
361 let rule_tag = pack_rule_tag(&p.pack_rule_id);
362
363 let already_at_version: Option<String> = sqlx::query_scalar(
366 "SELECT id FROM skills WHERE origin = ?1 AND tags LIKE '%' || ?2 || '%' \
367 AND tags LIKE '%' || ?3 || '%' LIMIT 1",
368 )
369 .bind(PACK_ORIGIN)
370 .bind(&rule_tag)
371 .bind(&version_tag)
372 .fetch_optional(&mut *tx)
373 .await?;
374 if already_at_version.is_some() {
375 continue;
376 }
377
378 let stale_ids: Vec<String> = sqlx::query_scalar(
382 "SELECT id FROM skills WHERE origin = ?1 AND tags LIKE '%' || ?2 || '%'",
383 )
384 .bind(PACK_ORIGIN)
385 .bind(&rule_tag)
386 .fetch_all(&mut *tx)
387 .await?;
388 for stale in &stale_ids {
389 sqlx::query("DELETE FROM rule_examples WHERE skill_id = ?1")
393 .bind(stale)
394 .execute(&mut *tx)
395 .await?;
396 sqlx::query("DELETE FROM skills WHERE id = ?1")
397 .bind(stale)
398 .execute(&mut *tx)
399 .await?;
400 let stale_dir = base_dir.join(stale);
402 let _ = std::fs::remove_dir_all(&stale_dir);
403 superseded_rule_ids.push(stale.clone());
404 }
405
406 let skill_dir = base_dir.join(&p.skill_id);
408 let skill_dir_for_check = canonical_base.join(&p.skill_id);
409 if !skill_dir_for_check.starts_with(&canonical_base) {
410 tx.rollback().await.ok();
411 cleanup_dirs(&written_dirs);
412 return Err(CoreError::Validation(
413 "install_pack: invalid slug after sanitization".into(),
414 ));
415 }
416 std::fs::create_dir_all(&skill_dir)
417 .map_err(|e| CoreError::Internal(format!("failed to create skill directory: {e}")))?;
418 let canonical_skill = skill_dir
419 .canonicalize()
420 .map_err(|e| CoreError::Internal(format!("failed to resolve skill directory: {e}")))?;
421 if !canonical_skill.starts_with(&canonical_base) {
422 tx.rollback().await.ok();
423 cleanup_dirs(&written_dirs);
424 return Err(CoreError::Validation("install_pack: path escape".into()));
425 }
426 std::fs::write(skill_dir.join("SKILL.md"), &p.skill_md)
427 .map_err(|e| CoreError::Internal(format!("failed to write SKILL.md: {e}")))?;
428 written_dirs.push(skill_dir.clone());
429
430 let engines_json = serde_json::to_string(&["claude"])?;
431 let tags_json = serde_json::to_string(&p.tags)?;
432 let file_patterns_json: Option<String> = if p.globs.is_empty() {
433 None
434 } else {
435 Some(serde_json::to_string(&p.globs)?)
436 };
437
438 sqlx::query(
444 "INSERT INTO skills
445 (id, name, source, directory, version, description, type, engines, tags,
446 trigger, check_prompt, file_patterns, source_repo, enabled_for_claude,
447 confidence_score, installed_at, updated_at, origin, content_hash, hash_created_at)
448 VALUES (?1, ?2, 'pack', ?3, ?4, ?5, 'review_standard', ?6, ?7,
449 NULL, NULL, ?8, ?9, 1, ?10, ?11, ?11, ?12, NULL, ?13)",
450 )
451 .bind(&p.skill_id)
452 .bind(&p.title)
453 .bind(&p.skill_id)
454 .bind(&manifest.version)
455 .bind(&p.body)
456 .bind(&engines_json)
457 .bind(&tags_json)
458 .bind(file_patterns_json.as_deref())
459 .bind(source_repo.as_str())
460 .bind(PACK_CONFIDENCE)
461 .bind(now.as_str())
462 .bind(PACK_ORIGIN)
463 .bind(now_ms)
464 .execute(&mut *tx)
465 .await?;
466
467 if let Some(example) = &p.example {
468 let ex_now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
469 sqlx::query(
470 "INSERT INTO rule_examples (id, skill_id, bad_code, good_code, description, source, created_at)
471 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
472 )
473 .bind(&example.id)
474 .bind(&example.skill_id)
475 .bind(&example.bad_code)
476 .bind(&example.good_code)
477 .bind(example.description.as_deref())
478 .bind(&example.source)
479 .bind(&ex_now)
480 .execute(&mut *tx)
481 .await?;
482 }
483 }
484
485 if let Err(e) = tx.commit().await {
486 cleanup_dirs(&written_dirs);
487 return Err(e.into());
488 }
489
490 for p in &prepared {
494 if let Err(e) =
495 crate::skill_fs::sync_engine_link(PACK_SKILL_SOURCE, &p.skill_id, "claude", true)
496 {
497 eprintln!(
498 "warning: sync_engine_link failed for pack rule {}: {e}",
499 p.skill_id
500 );
501 }
502 }
503
504 Ok(InstallPackOutcome {
505 pack_id: manifest.id.clone(),
506 pack_version: manifest.version.clone(),
507 rules: installed,
508 superseded_rule_ids,
509 dry_run: false,
510 })
511}
512
513fn cleanup_dirs(dirs: &[std::path::PathBuf]) {
516 for dir in dirs {
517 let _ = std::fs::remove_dir_all(dir);
518 }
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524 use crate::packs::manifest::{PackRuleExamples, PackTarget};
525
526 fn sample_manifest() -> PackManifest {
527 PackManifest {
528 schema_version: 1,
529 id: "difflore/go-http-safety".to_owned(),
530 name: "Go HTTP handler safety".to_owned(),
531 version: "1.2.0".to_owned(),
532 description: None,
533 target: Some(PackTarget {
534 languages: vec!["go".to_owned()],
535 frameworks: vec!["net/http".to_owned()],
536 file_globs: vec!["**/*.go".to_owned()],
537 }),
538 maintainer: None,
539 license: None,
540 provenance: None,
541 rules: vec![PackRule {
542 id: "go-http-safety/413-body-limit".to_owned(),
543 title: "Return 413 when a request body exceeds the size limit".to_owned(),
544 severity: Some("error".to_owned()),
545 file_globs: vec![],
546 tags: vec!["http".to_owned(), "security".to_owned()],
547 body: Some(
548 "Reject oversized request bodies with HTTP 413 instead of \
549 reading them unbounded into memory."
550 .to_owned(),
551 ),
552 examples: Some(PackRuleExamples {
553 bad: Some("data, _ := io.ReadAll(r.Body)".to_owned()),
554 good: Some("r.Body = http.MaxBytesReader(w, r.Body, max)".to_owned()),
555 description: Some("reviewer flagged unbounded read".to_owned()),
556 }),
557 provenance: None,
558 }],
559 }
560 }
561
562 #[test]
563 fn local_skill_id_is_namespaced_and_not_a_uuid() {
564 let id = local_skill_id("difflore/go-http-safety", "go-http-safety/413-body-limit");
565 assert!(id.starts_with("pack-difflore-go-http-safety-413-body-limit-"));
566 assert!(!id.contains('/'));
569 assert_eq!(
571 id,
572 local_skill_id("difflore/go-http-safety", "go-http-safety/413-body-limit")
573 );
574 }
575
576 #[test]
577 fn mandatory_tags_present() {
578 let manifest = sample_manifest();
579 let tags = build_tags(&manifest.rules[0], &manifest);
580 assert!(tags.contains(&"pack".to_owned()));
581 assert!(tags.contains(&"pack:difflore/go-http-safety@1.2.0".to_owned()));
582 assert!(tags.contains(&"pack-rule:go-http-safety/413-body-limit".to_owned()));
583 assert!(tags.contains(&"go".to_owned()));
584 assert!(tags.contains(&"severity:error".to_owned()));
585 assert!(tags.contains(&"http".to_owned()));
586 }
587
588 #[test]
589 fn rule_globs_override_pack_default() {
590 let mut manifest = sample_manifest();
591 manifest.rules[0].file_globs = vec!["internal/http/**/*.go".to_owned()];
592 let globs = effective_globs(&manifest.rules[0], &manifest);
593 assert_eq!(globs, vec!["internal/http/**/*.go".to_owned()]);
594 }
595
596 #[test]
597 fn pack_default_globs_used_when_rule_has_none() {
598 let manifest = sample_manifest();
599 let globs = effective_globs(&manifest.rules[0], &manifest);
600 assert_eq!(globs, vec!["**/*.go".to_owned()]);
601 }
602
603 #[test]
604 fn body_renders_via_item_six_code_spec() {
605 let manifest = sample_manifest();
606 let rule = &manifest.rules[0];
607 let globs = effective_globs(rule, &manifest);
608 let skill_id = local_skill_id(&manifest.id, &rule.id);
609 let example = build_example(&skill_id, rule);
610 let body = render_body(&skill_id, rule, &manifest, &globs, example.as_ref());
611 assert!(body.starts_with(&format!("## Rule {skill_id} —")));
613 assert!(body.contains("Scope: **/*.go"));
614 assert!(body.contains("Confidence: 0.55"));
615 assert!(body.contains("Origin: pack"));
616 assert!(body.contains("### Contract"));
617 assert!(body.contains("### Cases"));
618 assert!(body.contains("### Validation / Error matrix"));
621 }
622
623 #[test]
624 fn example_requires_both_sides() {
625 let manifest = sample_manifest();
626 let rule = &manifest.rules[0];
627 let skill_id = local_skill_id(&manifest.id, &rule.id);
628 assert!(build_example(&skill_id, rule).is_some());
629
630 let mut one_sided = rule.clone();
631 one_sided.examples = Some(PackRuleExamples {
632 bad: Some("x".to_owned()),
633 good: None,
634 description: None,
635 });
636 assert!(build_example(&skill_id, &one_sided).is_none());
637 }
638}