vtcode_desktop_protocol/
handoff_url.rs1use std::path::PathBuf;
11
12pub const HANDOFF_SCHEME: &str = "vtcode";
14
15#[derive(Clone, Debug, PartialEq, Eq)]
17pub struct HandoffLink {
18 pub session_id: String,
20 pub workspace: PathBuf,
22}
23
24#[must_use]
26pub fn handoff_url(link: &HandoffLink) -> String {
27 let session: String = url::form_urlencoded::byte_serialize(link.session_id.as_bytes()).collect();
28 let workspace: String =
29 url::form_urlencoded::byte_serialize(link.workspace.as_os_str().as_encoded_bytes()).collect();
30 format!("{HANDOFF_SCHEME}://session/{session}?workspace={workspace}")
31}
32
33#[must_use]
35pub fn parse_handoff_url(raw: &str) -> Option<HandoffLink> {
36 let url = url::Url::parse(raw).ok()?;
37 if url.scheme() != HANDOFF_SCHEME || url.host_str() != Some("session") {
38 return None;
39 }
40 let mut segments = url.path_segments()?;
41 let session_id = segments.next().filter(|id| !id.trim().is_empty())?;
42 if segments.next().is_some() {
43 return None;
44 }
45 let workspace = url
46 .query_pairs()
47 .find_map(|(key, value)| (key == "workspace").then_some(value))?;
48 if workspace.trim().is_empty() {
49 return None;
50 }
51 Some(HandoffLink {
52 session_id: session_id.trim().to_owned(),
53 workspace: PathBuf::from(workspace.into_owned()),
54 })
55}
56
57#[cfg(test)]
58mod tests {
59 use super::*;
60
61 fn link(session_id: &str, workspace: &str) -> HandoffLink {
62 HandoffLink {
63 session_id: session_id.to_string(),
64 workspace: PathBuf::from(workspace),
65 }
66 }
67
68 #[test]
69 fn canonical_form_is_stable() {
70 assert_eq!(
71 handoff_url(&link("thread-1", "/repo/workspace")),
72 "vtcode://session/thread-1?workspace=%2Frepo%2Fworkspace"
73 );
74 }
75
76 #[test]
77 fn build_parse_round_trips_hostile_paths() {
78 for original in [
79 link("thread-1", "/repo/workspace"),
80 link("session-label-9", "/sp ace/üñî +?&=#%"),
81 link("a", "/"),
82 ] {
83 let parsed = parse_handoff_url(&handoff_url(&original)).expect("round trip");
84 assert_eq!(parsed, original);
85 }
86 }
87
88 #[test]
89 fn rejects_non_handoff_urls() {
90 for raw in [
91 "",
92 "not a url",
93 "https://session/thread-1?workspace=/repo",
94 "vtcode://other/thread-1?workspace=/repo",
95 "vtcode:session/thread-1?workspace=/repo",
96 "vtcode://session/?workspace=/repo",
97 "vtcode://session/a/b?workspace=/repo",
98 "vtcode://session/thread-1",
99 "vtcode://session/thread-1?workspace=",
100 "vtcode://session/thread-1?workspace=%20%20",
101 "vtcode://session/thread-1?other=/repo",
102 ] {
103 assert_eq!(parse_handoff_url(raw), None, "{raw}");
104 }
105 }
106}