Skip to main content

runmat_package/identity/
source.rs

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}