1use super::{CanonicalPackageId, ContentDigest, PackageVersion, RegistryOrigin, RegistryReleaseId};
2use crate::IdentityError;
3use serde::{Deserialize, Serialize};
4use std::fmt::{Display, Formatter};
5use std::path::Path;
6use std::str::FromStr;
7use url::Url;
8
9#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
10#[serde(try_from = "String", into = "String")]
11pub struct NormalizedRelativePath(String);
12
13impl NormalizedRelativePath {
14 pub fn new(path: impl AsRef<Path>) -> Result<Self, IdentityError> {
15 let path = path.as_ref();
16 let Some(raw) = path.to_str() else {
17 return Err(invalid_path(&path.to_string_lossy(), "must be valid UTF-8"));
18 };
19 let portable = raw.replace('\\', "/");
20 if portable.is_empty() {
21 return Ok(Self(".".to_string()));
22 }
23 if portable.starts_with('/') || portable.as_bytes().get(1).is_some_and(|byte| *byte == b':')
24 {
25 return Err(invalid_path(&portable, "must be relative"));
26 }
27 let mut segments = Vec::new();
28 for segment in portable.split('/') {
29 match segment {
30 "" | "." => {}
31 ".." => {
32 return Err(invalid_path(&portable, "must not contain parent traversal"));
33 }
34 _ if segment
35 .chars()
36 .any(|character| character.is_control() || character == ':') =>
37 {
38 return Err(invalid_path(&portable, "contains a non-portable character"));
39 }
40 _ => segments.push(segment),
41 }
42 }
43 Ok(Self(if segments.is_empty() {
44 ".".to_string()
45 } else {
46 segments.join("/")
47 }))
48 }
49
50 pub fn as_str(&self) -> &str {
51 &self.0
52 }
53}
54
55impl Display for NormalizedRelativePath {
56 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
57 formatter.write_str(&self.0)
58 }
59}
60
61impl FromStr for NormalizedRelativePath {
62 type Err = IdentityError;
63
64 fn from_str(value: &str) -> Result<Self, Self::Err> {
65 Self::new(Path::new(value))
66 }
67}
68
69impl TryFrom<String> for NormalizedRelativePath {
70 type Error = IdentityError;
71
72 fn try_from(value: String) -> Result<Self, Self::Error> {
73 value.parse()
74 }
75}
76
77impl From<NormalizedRelativePath> for String {
78 fn from(value: NormalizedRelativePath) -> Self {
79 value.0
80 }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
84pub struct PathSourceId {
85 pub workspace_path: NormalizedRelativePath,
86 pub manifest_digest: ContentDigest,
87 pub tree_digest: ContentDigest,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
91#[serde(rename_all = "lowercase")]
92pub enum GitObjectAlgorithm {
93 Sha1,
94 Sha256,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
98pub struct GitCommitId {
99 pub algorithm: GitObjectAlgorithm,
100 pub hex: String,
101}
102
103impl FromStr for GitCommitId {
104 type Err = IdentityError;
105
106 fn from_str(value: &str) -> Result<Self, Self::Err> {
107 let (algorithm, length) = match value.len() {
108 40 => (GitObjectAlgorithm::Sha1, 40),
109 64 => (GitObjectAlgorithm::Sha256, 64),
110 _ => {
111 return Err(IdentityError::InvalidGitObjectId {
112 value: value.to_string(),
113 reason: "must be a full 40-digit SHA-1 or 64-digit SHA-256 object ID",
114 });
115 }
116 };
117 if value.len() != length
118 || value
119 .bytes()
120 .any(|byte| !byte.is_ascii_hexdigit() || byte.is_ascii_uppercase())
121 {
122 return Err(IdentityError::InvalidGitObjectId {
123 value: value.to_string(),
124 reason: "must use lowercase hexadecimal",
125 });
126 }
127 Ok(Self {
128 algorithm,
129 hex: value.to_string(),
130 })
131 }
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
135#[serde(try_from = "String", into = "String")]
136pub struct GitRepositoryUrl(String);
137
138impl GitRepositoryUrl {
139 pub fn new(value: &str) -> Result<Self, IdentityError> {
140 normalize_git_repository(value).map(Self)
141 }
142
143 pub fn as_str(&self) -> &str {
144 &self.0
145 }
146}
147
148impl Display for GitRepositoryUrl {
149 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
150 formatter.write_str(&self.0)
151 }
152}
153
154impl FromStr for GitRepositoryUrl {
155 type Err = IdentityError;
156
157 fn from_str(value: &str) -> Result<Self, Self::Err> {
158 Self::new(value)
159 }
160}
161
162impl TryFrom<String> for GitRepositoryUrl {
163 type Error = IdentityError;
164
165 fn try_from(value: String) -> Result<Self, Self::Error> {
166 Self::new(&value)
167 }
168}
169
170impl From<GitRepositoryUrl> for String {
171 fn from(value: GitRepositoryUrl) -> Self {
172 value.0
173 }
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
177pub struct GitSourceId {
178 pub repository: GitRepositoryUrl,
179 pub commit: GitCommitId,
180 pub subdir: NormalizedRelativePath,
181 pub tree_digest: ContentDigest,
182}
183
184impl GitSourceId {
185 pub fn new(
186 repository: &str,
187 commit: GitCommitId,
188 subdir: NormalizedRelativePath,
189 tree_digest: ContentDigest,
190 ) -> Result<Self, IdentityError> {
191 Ok(Self {
192 repository: GitRepositoryUrl::new(repository)?,
193 commit,
194 subdir,
195 tree_digest,
196 })
197 }
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
201pub struct ServerProjectSourceId {
202 pub service: String,
203 pub project: String,
204 pub snapshot: String,
205 pub tree_digest: ContentDigest,
206}
207
208impl ServerProjectSourceId {
209 pub fn normalize_service(service: &str) -> Result<String, IdentityError> {
210 normalize_service_origin(service)
211 }
212
213 pub fn new(
214 service: &str,
215 project: impl Into<String>,
216 snapshot: impl Into<String>,
217 tree_digest: ContentDigest,
218 ) -> Result<Self, IdentityError> {
219 let service = normalize_service_origin(service)?;
220 let project = project.into();
221 let snapshot = snapshot.into();
222 if project.trim().is_empty() || snapshot.trim().is_empty() {
223 return Err(IdentityError::InvalidServerProjectSource {
224 value: format!("{service}/{project}@{snapshot}"),
225 reason: "project and snapshot IDs must be non-empty",
226 });
227 }
228 Ok(Self {
229 service,
230 project,
231 snapshot,
232 tree_digest,
233 })
234 }
235}
236
237#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
238pub struct RegistrySourceId {
239 pub registry_origin: RegistryOrigin,
240 pub package: CanonicalPackageId,
241 pub release: RegistryReleaseId,
242 pub version: PackageVersion,
243 pub release_digest: ContentDigest,
244 pub artifact_digest: ContentDigest,
245 pub tree_digest: ContentDigest,
246}
247
248impl RegistrySourceId {
249 pub fn validate(&self) -> Result<(), IdentityError> {
250 RegistryOrigin::new(self.registry_origin.as_str())?;
251 RegistryReleaseId::new(self.release.as_str())?;
252 Ok(())
253 }
254}
255
256#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
257#[serde(tag = "kind", content = "source", rename_all = "kebab-case")]
258pub enum SourceId {
259 Path(PathSourceId),
260 Git(GitSourceId),
261 ServerProject(ServerProjectSourceId),
262 Registry(RegistrySourceId),
263}
264
265impl SourceId {
266 pub fn tree_digest(&self) -> &ContentDigest {
267 match self {
268 Self::Path(source) => &source.tree_digest,
269 Self::Git(source) => &source.tree_digest,
270 Self::ServerProject(source) => &source.tree_digest,
271 Self::Registry(source) => &source.tree_digest,
272 }
273 }
274
275 pub fn validate(&self) -> Result<(), IdentityError> {
276 match self {
277 Self::Path(_) => Ok(()),
278 Self::Git(source) => {
279 let parsed: GitCommitId = source.commit.hex.parse()?;
280 if parsed.algorithm != source.commit.algorithm {
281 return Err(IdentityError::InvalidGitObjectId {
282 value: source.commit.hex.clone(),
283 reason: "object algorithm does not match object ID length",
284 });
285 }
286 GitRepositoryUrl::new(source.repository.as_str())?;
287 NormalizedRelativePath::new(source.subdir.as_str())?;
288 Ok(())
289 }
290 Self::ServerProject(source) => {
291 ServerProjectSourceId::new(
292 &source.service,
293 source.project.clone(),
294 source.snapshot.clone(),
295 source.tree_digest.clone(),
296 )?;
297 Ok(())
298 }
299 Self::Registry(source) => source.validate(),
300 }
301 }
302}
303
304impl Display for SourceId {
305 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
306 match self {
307 Self::Path(source) => write!(
308 formatter,
309 "path:{}#{}",
310 source.workspace_path, source.tree_digest
311 ),
312 Self::Git(source) => write!(
313 formatter,
314 "git:{}@{}:{}#{}",
315 source.repository, source.commit.hex, source.subdir, source.tree_digest
316 ),
317 Self::ServerProject(source) => write!(
318 formatter,
319 "project:{}:{}@{}#{}",
320 source.service, source.project, source.snapshot, source.tree_digest
321 ),
322 Self::Registry(source) => write!(
323 formatter,
324 "registry:{}:{}@{}:{}#{}",
325 source.registry_origin,
326 source.package,
327 source.version,
328 source.release,
329 source.tree_digest
330 ),
331 }
332 }
333}
334
335fn normalize_git_repository(value: &str) -> Result<String, IdentityError> {
336 let mut url = Url::parse(value).map_err(|_| IdentityError::InvalidGitSource {
337 value: value.to_string(),
338 reason: "must be an absolute URL",
339 })?;
340 reject_url_secrets(&url, value)?;
341 if !matches!(url.scheme(), "https" | "ssh") {
342 return Err(IdentityError::InvalidGitSource {
343 value: value.to_string(),
344 reason: "scheme must be `https` or `ssh`",
345 });
346 }
347 url.set_fragment(None);
348 url.set_query(None);
349 normalize_default_port(&mut url);
350 let normalized_path = url.path().trim_end_matches('/').to_string();
351 url.set_path(if normalized_path.is_empty() {
352 "/"
353 } else {
354 &normalized_path
355 });
356 Ok(url.to_string())
357}
358
359fn normalize_service_origin(value: &str) -> Result<String, IdentityError> {
360 let mut url = Url::parse(value).map_err(|_| IdentityError::InvalidServerProjectSource {
361 value: value.to_string(),
362 reason: "service must be an absolute HTTPS origin",
363 })?;
364 reject_url_secrets(&url, value).map_err(|_| IdentityError::InvalidServerProjectSource {
365 value: value.to_string(),
366 reason: "service origin cannot contain credentials, query, or fragment",
367 })?;
368 if url.scheme() != "https" {
369 return Err(IdentityError::InvalidServerProjectSource {
370 value: value.to_string(),
371 reason: "service origin must use HTTPS",
372 });
373 }
374 normalize_default_port(&mut url);
375 let normalized_path = url.path().trim_end_matches('/').to_string();
376 url.set_path(&normalized_path);
377 url.set_query(None);
378 url.set_fragment(None);
379 Ok(url.to_string().trim_end_matches('/').to_string())
380}
381
382fn reject_url_secrets(url: &Url, value: &str) -> Result<(), IdentityError> {
383 if !url.username().is_empty()
384 || url.password().is_some()
385 || url.query().is_some()
386 || url.fragment().is_some()
387 {
388 return Err(IdentityError::InvalidGitSource {
389 value: value.to_string(),
390 reason: "credentials, query parameters, and fragments are prohibited",
391 });
392 }
393 Ok(())
394}
395
396fn normalize_default_port(url: &mut Url) {
397 let is_default = matches!(
398 (url.scheme(), url.port()),
399 ("https", Some(443)) | ("ssh", Some(22))
400 );
401 if is_default {
402 let _ = url.set_port(None);
403 }
404}
405
406fn invalid_path(value: &str, reason: &'static str) -> IdentityError {
407 IdentityError::InvalidRelativePath {
408 value: value.to_string(),
409 reason,
410 }
411}