use crate::error::{Error, Result};
use crate::model::Adapter;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::path::{Component, Path, PathBuf};
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(transparent)]
pub struct ProjectionFile(String);
impl ProjectionFile {
pub fn unit_relative(path: &Path) -> Self {
Self(path.to_string_lossy().into_owned())
}
pub fn is_root(&self) -> bool {
self.0.starts_with('/')
}
pub fn workspace_path(&self, unit_path: &Path) -> PathBuf {
match self.0.strip_prefix('/') {
Some(segments) => segments.split('/').collect(),
None => normalized(&unit_path.join(&self.0)),
}
}
pub(crate) fn validate(&self, release_unit_id: &str, adapter: Adapter) -> Result<()> {
let description = format!("release unit {release_unit_id} projection file");
if self.is_root() || self.0.starts_with('\\') || has_drive_prefix(&self.0) {
if !is_canonical_root(&self.0) {
return Err(Error::Validation(format!(
"{description} {} must be a root path of the form /dir/file with no empty, . or .. segments, no \\ and no drive prefix",
self.0
)));
}
if !adapter.requires_pointer() {
return Err(Error::Validation(format!(
"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",
format!("{adapter:?}").to_lowercase()
)));
}
return Ok(());
}
validate_relative_path(Path::new(&self.0), &description)
}
fn as_path(&self) -> &Path {
Path::new(&self.0)
}
}
impl PartialEq for ProjectionFile {
fn eq(&self, other: &Self) -> bool {
self.as_path() == other.as_path()
}
}
impl Eq for ProjectionFile {}
impl PartialEq<&str> for ProjectionFile {
fn eq(&self, other: &&str) -> bool {
self.as_path() == Path::new(other)
}
}
impl fmt::Display for ProjectionFile {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
fn is_canonical_root(text: &str) -> bool {
text.strip_prefix('/').is_some_and(|segments| {
!segments.contains('\\')
&& segments
.split('/')
.all(|segment| !matches!(segment, "" | "." | "..") && !has_drive_prefix(segment))
})
}
fn has_drive_prefix(text: &str) -> bool {
let bytes = text.as_bytes();
bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
}
pub(crate) fn normalized(relative: &Path) -> PathBuf {
relative
.components()
.filter(|component| !matches!(component, Component::CurDir))
.collect()
}
pub(crate) fn validate_relative_path(path: &Path, description: &str) -> Result<()> {
if path.as_os_str().is_empty()
|| path.is_absolute()
|| path
.components()
.any(|component| matches!(component, Component::ParentDir))
{
return Err(Error::Validation(format!(
"{description} must be a non-empty relative path without .."
)));
}
let text = path.to_string_lossy();
if let Some(segment) = text
.split(['/', '\\'])
.find(|segment| has_drive_prefix(segment))
{
return Err(Error::Validation(format!(
"{description} {text} has a segment {segment} that starts with a drive prefix; on Windows the path would leave the repository"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unit_relative_file_resolves_below_the_unit_path() {
let file = ProjectionFile::unit_relative(Path::new("./meta.yml"));
assert_eq!(
file.workspace_path(Path::new("site")),
PathBuf::from("site/meta.yml")
);
assert_eq!(
file.workspace_path(Path::new(".")),
PathBuf::from("meta.yml")
);
}
}