Skip to main content

varve_core/
vsixexport.rs

1//! VS Code extension export (REQ-VSIX-001 clause 3).
2//!
3//! A `vsix`-kind entry carries one `.vsix` file — a zip, nothing more, which is
4//! why extensions could ship in this release while the tree-shaped SDK store
5//! (varve#67) could not. `export-vsix` lays the verified bytes out as files
6//! `code --install-extension <file>` consumes directly:
7//!
8//! ```text
9//! D/rust-lang.rust-analyzer-0.3.2260.vsix
10//! D/vadimcn.vscode-lldb-1.11.4.vsix
11//! D/.varve-export.json
12//! ```
13//!
14//! The trust chain needs nothing new. A `.vsix` is signed like any blob (its
15//! digest is in the DSSE-signed layer manifest) and the digest check is
16//! kind-agnostic (DD-003). What this module owes the caller is the FILE NAME:
17//! `code` dispatches on the `.vsix` suffix, refusing anything else outright,
18//! and a human reading the directory has only the name to tell one extension
19//! from another. So the marketplace convention — `publisher.name-version.vsix`
20//! — is reproduced exactly, with the entry's payload name supplying the
21//! `publisher.name` half.
22//!
23//! Names come out of a SIGNED manifest, which makes them attributable, not
24//! benign: `../../evil` signed by a realm root must not place bytes outside
25//! the export directory, and a name starting with `-` must not reach `code`'s
26//! argument parser as a flag. Both are refused before anything is written.
27
28use std::collections::BTreeMap;
29use std::path::{Path, PathBuf};
30
31/// One extension to lay out: its marketplace identity and the `.vsix` bytes.
32#[derive(Debug, Clone)]
33pub struct VsixEntry {
34    /// The extension identity, conventionally `publisher.name`
35    /// (e.g. `rust-lang.rust-analyzer`) — the payload's name annotation.
36    pub name: String,
37    /// The extension version, e.g. `0.3.2260`.
38    pub version: String,
39    /// The verified `.vsix` bytes.
40    pub bytes: Vec<u8>,
41}
42
43/// The file extension `code --install-extension` dispatches on. It accepts a
44/// path only when it ends in this; anything else is treated as a marketplace
45/// ID and fetched from the network — the exact behaviour this export exists
46/// to avoid.
47pub const VSIX_SUFFIX: &str = ".vsix";
48
49/// The file name for an extension: `publisher.name-version.vsix`, the
50/// marketplace's own asset convention, so the file a user sees in the export
51/// directory reads the same as the one they would have downloaded.
52///
53/// Total by construction — it never inspects the strings. `validate_entries`
54/// is what refuses a name this could not express safely, and every writer here
55/// runs it first.
56pub fn vsix_file_name(name: &str, version: &str) -> String {
57    format!("{name}-{version}{VSIX_SUFFIX}")
58}
59
60/// Why an extension could not be exported.
61#[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
88/// Is this string a single, safe, `code`-passable path component?
89fn 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    // A leading '-' reaches `code --install-extension` as a flag, not a file.
97    if value.starts_with('-') {
98        return Some("starts with '-', which `code` would read as a flag".into());
99    }
100
101    // A leading '.' hides the file and is the first character of `..`.
102    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
116/// Refuse an extension id that cannot be a file name — before a byte is
117/// written, so a bad entry leaves the export directory untouched rather than
118/// half-populated.
119pub 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
129/// Refuse a version that cannot be a file name, for the same reasons.
130pub 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
141/// Resolve every destination before writing any of them: an unusable id or a
142/// collision must leave the export directory untouched, not half-written. The
143/// same discipline `Store::lay_down_payloads` follows, for the same reason —
144/// the alternative is the wrong bytes under the right name.
145fn 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
166/// Lay the verified extensions out in `out` as `publisher.name-version.vsix`
167/// files. Returns the number written.
168///
169/// A `.vsix` is NOT made executable: it is a zip handed to `code`, and the
170/// store already withholds the execute bit from every non-dispatchable payload
171/// (REQ-VSIX-001 clause 2). Exporting it as 0o755 would undo that at the last
172/// step, so the mode is set explicitly rather than inherited from the umask.
173pub 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    // rivet: verifies REQ-VSIX-001
206    #[test]
207    fn the_file_name_is_the_one_code_and_a_human_both_read() {
208        // `code --install-extension` dispatches on the `.vsix` suffix — without
209        // it the argument is treated as a marketplace ID and FETCHED, which is
210        // the network round-trip this whole requirement exists to remove. The
211        // rest is the marketplace's own asset convention.
212        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        // The version is IN the name, which is what lets two versions of one
218        // extension sit in one directory (clause 4).
219        assert_ne!(
220            vsix_file_name("a.b", "1.0.0"),
221            vsix_file_name("a.b", "2.0.0")
222        );
223    }
224
225    // rivet: verifies REQ-VSIX-001
226    #[test]
227    fn an_id_that_could_escape_or_look_like_a_flag_is_refused() {
228        // These strings come out of a SIGNED manifest. Signed means
229        // attributable, not benign.
230        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        // The refusal must name the ACTUAL fault, or the depositor applies the
251        // wrong fix. `.` and `..` are relative path elements — a distinct
252        // problem from a hidden dotfile, with a distinct correction.
253        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    // rivet: verifies REQ-VSIX-001
277    #[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        // …and the good entry did not land either: a half-written export is a
293        // directory a consumer would install from and believe complete.
294        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    // rivet: verifies REQ-VSIX-001
301    #[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    // rivet: verifies REQ-VSIX-001
318    #[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        // Two extensions, and two VERSIONS of one of them (clause 4).
323        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}