1use std::collections::BTreeMap;
29use std::path::{Path, PathBuf};
30
31#[derive(Debug, Clone)]
33pub struct VsixEntry {
34 pub name: String,
37 pub version: String,
39 pub bytes: Vec<u8>,
41}
42
43pub const VSIX_SUFFIX: &str = ".vsix";
48
49pub fn vsix_file_name(name: &str, version: &str) -> String {
57 format!("{name}-{version}{VSIX_SUFFIX}")
58}
59
60#[derive(Debug, thiserror::Error)]
62pub enum VsixExportError {
63 #[error("io error at {path}")]
64 Io {
65 path: String,
66 #[source]
67 source: std::io::Error,
68 },
69 #[error("extension id {name:?} cannot be exported: {why}")]
70 UnrepresentableName { name: String, why: String },
71 #[error("extension {name:?} has a version {version:?} that cannot be exported: {why}")]
72 UnrepresentableVersion {
73 name: String,
74 version: String,
75 why: String,
76 },
77 #[error(
78 "extensions {first} and {second} both export to {file} — one would overwrite the \
79 other, and the survivor would carry the wrong bytes under the right name"
80 )]
81 Collision {
82 file: String,
83 first: String,
84 second: String,
85 },
86}
87
88fn component_fault(value: &str) -> Option<String> {
90 if value.is_empty() {
91 return Some("empty".into());
92 }
93 if value == "." || value == ".." {
94 return Some("a relative path element".into());
95 }
96 if value.starts_with('-') {
98 return Some("starts with '-', which `code` would read as a flag".into());
99 }
100
101 if value.starts_with('.') {
103 return Some("starts with '.', which hides the exported file".into());
104 }
105 if let Some(bad) = value
106 .chars()
107 .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')))
108 {
109 return Some(format!(
110 "contains {bad:?}; an extension id is ASCII alphanumeric, '.', '-' or '_'"
111 ));
112 }
113 None
114}
115
116pub fn validate_extension_id(name: &str) -> Result<(), VsixExportError> {
120 match component_fault(name) {
121 None => Ok(()),
122 Some(why) => Err(VsixExportError::UnrepresentableName {
123 name: name.to_string(),
124 why,
125 }),
126 }
127}
128
129pub fn validate_extension_version(name: &str, version: &str) -> Result<(), VsixExportError> {
131 match component_fault(version) {
132 None => Ok(()),
133 Some(why) => Err(VsixExportError::UnrepresentableVersion {
134 name: name.to_string(),
135 version: version.to_string(),
136 why,
137 }),
138 }
139}
140
141fn plan(entries: &[VsixEntry]) -> Result<Vec<(PathBuf, &VsixEntry)>, VsixExportError> {
146 let mut placed: BTreeMap<String, String> = BTreeMap::new();
147 let mut planned = Vec::with_capacity(entries.len());
148 for e in entries {
149 validate_extension_id(&e.name)?;
150 validate_extension_version(&e.name, &e.version)?;
151 let file = vsix_file_name(&e.name, &e.version);
152 let who = format!("{}@{}", e.name, e.version);
153 if let Some(first) = placed.get(&file) {
154 return Err(VsixExportError::Collision {
155 file,
156 first: first.clone(),
157 second: who,
158 });
159 }
160 placed.insert(file.clone(), who);
161 planned.push((PathBuf::from(file), e));
162 }
163 Ok(planned)
164}
165
166pub fn export_vsix(entries: &[VsixEntry], out: &Path) -> Result<usize, VsixExportError> {
174 let planned = plan(entries)?;
175 let io = |path: &Path, source: std::io::Error| VsixExportError::Io {
176 path: path.display().to_string(),
177 source,
178 };
179 std::fs::create_dir_all(out).map_err(|e| io(out, e))?;
180 for (rel, entry) in planned {
181 let path = out.join(rel);
182 std::fs::write(&path, &entry.bytes).map_err(|e| io(&path, e))?;
183 #[cfg(unix)]
184 {
185 use std::os::unix::fs::PermissionsExt;
186 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644))
187 .map_err(|e| io(&path, e))?;
188 }
189 }
190 Ok(entries.len())
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 fn entry(name: &str, version: &str, bytes: &[u8]) -> VsixEntry {
198 VsixEntry {
199 name: name.into(),
200 version: version.into(),
201 bytes: bytes.to_vec(),
202 }
203 }
204
205 #[test]
207 fn the_file_name_is_the_one_code_and_a_human_both_read() {
208 assert_eq!(
213 vsix_file_name("rust-lang.rust-analyzer", "0.3.2260"),
214 "rust-lang.rust-analyzer-0.3.2260.vsix"
215 );
216 assert!(vsix_file_name("a.b", "1.0.0").ends_with(".vsix"));
217 assert_ne!(
220 vsix_file_name("a.b", "1.0.0"),
221 vsix_file_name("a.b", "2.0.0")
222 );
223 }
224
225 #[test]
227 fn an_id_that_could_escape_or_look_like_a_flag_is_refused() {
228 for bad in [
231 "../../evil",
232 "pub/name",
233 "pub\\name",
234 "",
235 ".",
236 "..",
237 ".hidden",
238 "--force",
239 "name with spaces",
240 "name;rm -rf /",
241 ] {
242 assert!(
243 validate_extension_id(bad).is_err(),
244 "id {bad:?} must be refused, not written"
245 );
246 }
247 for good in ["rust-lang.rust-analyzer", "vadimcn.vscode-lldb", "my_ext"] {
248 assert!(validate_extension_id(good).is_ok(), "{good} is a real id");
249 }
250 for (bad, why) in [
254 (".", "a relative path element"),
255 ("..", "a relative path element"),
256 (".hidden", "hides the exported file"),
257 ("--force", "`code` would read as a flag"),
258 ("pub/name", "contains '/'"),
259 ] {
260 let msg = validate_extension_id(bad).unwrap_err().to_string();
261 assert!(
262 msg.contains(why),
263 "the refusal of {bad:?} must say {why:?}, got: {msg}"
264 );
265 }
266 for bad in ["../1.0.0", "1.0/0", "", "-1.0.0"] {
267 assert!(
268 validate_extension_version("pub.name", bad).is_err(),
269 "version {bad:?} must be refused"
270 );
271 }
272 assert!(validate_extension_version("pub.name", "0.3.2260").is_ok());
273 assert!(validate_extension_version("pub.name", "1.0.0-rc.1").is_ok());
274 }
275
276 #[test]
278 fn nothing_is_written_when_one_entry_is_unexportable() {
279 let tmp = tempfile::tempdir().unwrap();
280 let out = tmp.path().join("ext");
281 let outside = tmp.path().join("OUTSIDE");
282 std::fs::create_dir_all(&outside).unwrap();
283 let entries = [
284 entry("good.ext", "1.0.0", b"good"),
285 entry("../../OUTSIDE/evil", "1.0.0", b"evil"),
286 ];
287 assert!(export_vsix(&entries, &out).is_err());
288 assert!(
289 std::fs::read_dir(&outside).unwrap().next().is_none(),
290 "a signed name must not place bytes outside the export directory"
291 );
292 assert!(
295 !out.join("good.ext-1.0.0.vsix").exists(),
296 "the export must be refused whole, not written up to the bad entry"
297 );
298 }
299
300 #[test]
302 fn two_entries_that_would_share_a_file_are_refused_not_overwritten() {
303 let tmp = tempfile::tempdir().unwrap();
304 let out = tmp.path().join("ext");
305 let entries = [
306 entry("pub.name", "1.0.0", b"first"),
307 entry("pub.name", "1.0.0", b"second"),
308 ];
309 let err = export_vsix(&entries, &out).unwrap_err();
310 assert!(
311 matches!(err, VsixExportError::Collision { .. }),
312 "expected a collision, got {err}"
313 );
314 assert!(!out.join("pub.name-1.0.0.vsix").exists());
315 }
316
317 #[test]
319 fn every_extension_lands_with_its_own_bytes_and_no_execute_bit() {
320 let tmp = tempfile::tempdir().unwrap();
321 let out = tmp.path().join("extensions");
322 let entries = [
324 entry("rust-lang.rust-analyzer", "0.3.2260", b"ra-old-zip"),
325 entry("rust-lang.rust-analyzer", "0.3.2300", b"ra-new-zip"),
326 entry("vadimcn.vscode-lldb", "1.11.4", b"lldb-zip"),
327 ];
328 assert_eq!(export_vsix(&entries, &out).unwrap(), 3);
329 for (file, want) in [
330 ("rust-lang.rust-analyzer-0.3.2260.vsix", &b"ra-old-zip"[..]),
331 ("rust-lang.rust-analyzer-0.3.2300.vsix", &b"ra-new-zip"[..]),
332 ("vadimcn.vscode-lldb-1.11.4.vsix", &b"lldb-zip"[..]),
333 ] {
334 let path = out.join(file);
335 assert_eq!(
336 std::fs::read(&path).unwrap(),
337 want,
338 "{file} must hold ITS OWN bytes"
339 );
340 #[cfg(unix)]
341 {
342 use std::os::unix::fs::PermissionsExt;
343 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
344 assert_eq!(
345 mode & 0o111,
346 0,
347 "{file} is a zip handed to `code`, not a program: mode {mode:o}"
348 );
349 }
350 }
351 }
352}