strop_workspace/
resource.rs1use std::path::PathBuf;
7
8use crate::addr::RemoteEndpoint;
9use crate::filesystem::Filesystem;
10
11#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
15pub struct ResourceLocation {
16 #[serde(alias = "target")]
17 pub filesystem: Filesystem,
18 #[serde(with = "strop_core::path_serde")]
19 pub path: PathBuf,
20}
21
22impl ResourceLocation {
23 pub fn uri(&self) -> Result<String, crate::AddressError> {
26 if !self.path.is_absolute() {
27 return Err(crate::AddressError::RelativePath);
28 }
29 if crate::addr::uri::path_bytes(&self.path).contains(&0) {
30 return Err(crate::AddressError::NulInPath);
31 }
32 let mut value = match &self.filesystem {
33 Filesystem::Local => "file://".to_owned(),
34 Filesystem::Remote(endpoint) => {
35 return crate::RemoteFile::from_path(endpoint.clone(), self.path.clone())
36 .map(|file| file.to_string())
37 }
38 Filesystem::Container(id) => format!("container:{id}"),
39 };
40 crate::addr::uri::push_escaped(&mut value, crate::addr::uri::path_bytes(&self.path));
41 Ok(value)
42 }
43
44 pub fn parse_uri(value: &str) -> Result<Self, crate::AddressError> {
45 if value.starts_with("ssh://") {
46 let file = crate::RemoteFile::parse(value)?;
47 return Ok(Self::remote(
48 file.endpoint().clone(),
49 file.path().to_path_buf(),
50 ));
51 }
52 let (filesystem, raw_path) = if let Some(rest) = value.strip_prefix("file://") {
53 let path = if rest.starts_with('/') {
54 rest
55 } else {
56 rest.strip_prefix("localhost")
57 .filter(|path| path.starts_with('/'))
58 .ok_or(crate::AddressError::NonLocalFileAuthority)?
59 };
60 (Filesystem::Local, path)
61 } else if let Some(rest) = value.strip_prefix("container:") {
62 let slash = rest
63 .find('/')
64 .ok_or(crate::AddressError::InvalidContainerLocation)?;
65 let id = crate::ContainerId::canonical(rest[..slash].to_owned())
66 .map_err(|_| crate::AddressError::InvalidContainerLocation)?;
67 (Filesystem::Container(id), &rest[slash..])
68 } else {
69 return Err(crate::AddressError::UnsupportedResourceUri);
70 };
71 let path = crate::addr::uri::decode_path(raw_path)?;
72 if !path.is_absolute() {
73 return Err(crate::AddressError::RelativePath);
74 }
75 Ok(Self { filesystem, path })
76 }
77 pub fn local(path: PathBuf) -> Self {
78 Self {
79 filesystem: Filesystem::Local,
80 path,
81 }
82 }
83
84 pub fn remote(endpoint: RemoteEndpoint, path: PathBuf) -> Self {
85 Self {
86 filesystem: Filesystem::Remote(endpoint),
87 path,
88 }
89 }
90
91 pub fn relocated(&self, source: &Self, destination: &Self) -> Option<Self> {
94 if self.filesystem != source.filesystem || source.filesystem != destination.filesystem {
95 return None;
96 }
97 let relative = self.path.strip_prefix(&source.path).ok()?;
98 Some(Self {
99 filesystem: self.filesystem.clone(),
100 path: if relative.as_os_str().is_empty() {
101 destination.path.clone()
102 } else {
103 destination.path.join(relative)
104 },
105 })
106 }
107
108 pub fn label(&self) -> String {
111 let path = crate::directory::display_path(&self.path);
112 match &self.filesystem {
113 Filesystem::Local => path,
114 other => format!("{}{path}", other.label()),
115 }
116 }
117
118 pub fn local_path(&self) -> Option<&std::path::Path> {
121 match &self.filesystem {
122 Filesystem::Local => Some(&self.path),
123 _ => None,
124 }
125 }
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131
132 #[test]
133 fn decodes_the_legacy_docpath_shape() {
134 let legacy = serde_json::json!({
136 "target": "Local",
137 "path": "/workspace/a.rs",
138 });
139 let location: ResourceLocation = serde_json::from_value(legacy).unwrap();
140 assert_eq!(location.filesystem, Filesystem::Local);
141 assert_eq!(location.path, PathBuf::from("/workspace/a.rs"));
142 let endpoint = RemoteEndpoint::parse("ssh://user@example.com:2222").unwrap();
143 let legacy = serde_json::json!({
144 "target": { "Remote": endpoint },
145 "path": "/var/log/app.log",
146 });
147 let location: ResourceLocation = serde_json::from_value(legacy).unwrap();
148 assert_eq!(location.filesystem, Filesystem::Remote(endpoint));
149 }
150
151 #[test]
152 fn remote_resources_never_expose_a_local_path() {
153 let endpoint = RemoteEndpoint::parse("ssh://example.com").unwrap();
154 let location = ResourceLocation::remote(endpoint, PathBuf::from("/etc/hostname"));
155 assert_eq!(location.local_path(), None);
156 assert!(location.label().starts_with("ssh://example.com"));
157 }
158
159 #[test]
160 fn resource_uri_roundtrips_without_namespace_fallback() {
161 let local = ResourceLocation::local("/tmp/a b#c%/ssh:literal".into());
162 assert_eq!(
163 ResourceLocation::parse_uri(&local.uri().unwrap()).unwrap(),
164 local
165 );
166 assert!(ResourceLocation::parse_uri("file://another-host/tmp/a").is_err());
167 assert!(ResourceLocation::parse_uri("file:///tmp/%00").is_err());
168 assert!(ResourceLocation::parse_uri("container:short/tmp/a").is_err());
169 let container = ResourceLocation {
170 filesystem: Filesystem::Container(
171 crate::ContainerId::canonical("a".repeat(64)).unwrap(),
172 ),
173 path: "/tmp/a b".into(),
174 };
175 assert_eq!(
176 ResourceLocation::parse_uri(&container.uri().unwrap()).unwrap(),
177 container
178 );
179 }
180
181 #[cfg(unix)]
182 #[test]
183 fn non_utf8_names_and_literal_escape_text_have_distinct_display_and_uri() {
184 use std::os::unix::ffi::OsStringExt;
185 let path = PathBuf::from(std::ffi::OsString::from_vec(b"/tmp/a\xff".to_vec()));
186 let resource = ResourceLocation::local(path);
187 let literal = ResourceLocation::local("/tmp/a\\xFF".into());
188 assert_ne!(resource.label(), literal.label());
189 assert_ne!(resource.uri().unwrap(), literal.uri().unwrap());
190 assert_eq!(
191 ResourceLocation::parse_uri(&resource.uri().unwrap()).unwrap(),
192 resource
193 );
194 }
195}