intentional_core/
projection_file.rs1use crate::error::{Error, Result};
9use crate::model::Adapter;
10use serde::{Deserialize, Serialize};
11use std::fmt;
12use std::path::{Component, Path, PathBuf};
13
14#[derive(Debug, Clone, Deserialize, Serialize)]
24#[serde(transparent)]
25pub struct ProjectionFile(String);
26
27impl ProjectionFile {
28 pub fn unit_relative(path: &Path) -> Self {
30 Self(path.to_string_lossy().into_owned())
31 }
32
33 pub fn is_root(&self) -> bool {
35 self.0.starts_with('/')
36 }
37
38 pub fn workspace_path(&self, unit_path: &Path) -> PathBuf {
40 match self.0.strip_prefix('/') {
41 Some(segments) => segments.split('/').collect(),
42 None => normalized(&unit_path.join(&self.0)),
43 }
44 }
45
46 pub(crate) fn validate(&self, release_unit_id: &str, adapter: Adapter) -> Result<()> {
55 let description = format!("release unit {release_unit_id} projection file");
56 if self.is_root() || self.0.starts_with('\\') || has_drive_prefix(&self.0) {
57 if !is_canonical_root(&self.0) {
58 return Err(Error::Validation(format!(
59 "{description} {} must be a root path of the form /dir/file with no empty, . or .. segments, no \\ and no drive prefix",
60 self.0
61 )));
62 }
63 if !adapter.requires_pointer() {
64 return Err(Error::Validation(format!(
65 "release unit {release_unit_id} {} projection file must be relative to the release-unit path; only json, toml, and yaml projections may use a root path",
66 format!("{adapter:?}").to_lowercase()
67 )));
68 }
69 return Ok(());
70 }
71 validate_relative_path(Path::new(&self.0), &description)
72 }
73
74 fn as_path(&self) -> &Path {
75 Path::new(&self.0)
76 }
77}
78
79impl PartialEq for ProjectionFile {
80 fn eq(&self, other: &Self) -> bool {
81 self.as_path() == other.as_path()
82 }
83}
84
85impl Eq for ProjectionFile {}
86
87impl PartialEq<&str> for ProjectionFile {
88 fn eq(&self, other: &&str) -> bool {
89 self.as_path() == Path::new(other)
90 }
91}
92
93impl fmt::Display for ProjectionFile {
94 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
95 formatter.write_str(&self.0)
96 }
97}
98
99fn is_canonical_root(text: &str) -> bool {
102 text.strip_prefix('/').is_some_and(|segments| {
103 !segments.contains('\\')
104 && segments
105 .split('/')
106 .all(|segment| !matches!(segment, "" | "." | "..") && !has_drive_prefix(segment))
107 })
108}
109
110fn has_drive_prefix(text: &str) -> bool {
113 let bytes = text.as_bytes();
114 bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
115}
116
117pub(crate) fn normalized(relative: &Path) -> PathBuf {
119 relative
120 .components()
121 .filter(|component| !matches!(component, Component::CurDir))
122 .collect()
123}
124
125pub(crate) fn validate_relative_path(path: &Path, description: &str) -> Result<()> {
129 if path.as_os_str().is_empty()
130 || path.is_absolute()
131 || path
132 .components()
133 .any(|component| matches!(component, Component::ParentDir))
134 {
135 return Err(Error::Validation(format!(
136 "{description} must be a non-empty relative path without .."
137 )));
138 }
139 let text = path.to_string_lossy();
140 if let Some(segment) = text
141 .split(['/', '\\'])
142 .find(|segment| has_drive_prefix(segment))
143 {
144 return Err(Error::Validation(format!(
145 "{description} {text} has a segment {segment} that starts with a drive prefix; on Windows the path would leave the repository"
146 )));
147 }
148 Ok(())
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 #[test]
156 fn unit_relative_file_resolves_below_the_unit_path() {
157 let file = ProjectionFile::unit_relative(Path::new("./meta.yml"));
158 assert_eq!(
159 file.workspace_path(Path::new("site")),
160 PathBuf::from("site/meta.yml")
161 );
162 assert_eq!(
163 file.workspace_path(Path::new(".")),
164 PathBuf::from("meta.yml")
165 );
166 }
167}