1use std::{fmt, path::Path, str::FromStr};23use camino::{Utf8Component, Utf8Path, Utf8PathBuf};4use serde::{Deserialize, Serialize, de};56#[derive(Debug, Clone, Serialize, Deserialize)]7pub struct JsonnetFile {8 pub version: u32,9 #[serde(default)]10 pub dependencies: Vec<Dependency>,11 #[serde(default = "legacy_imports_default", rename = "legacyImports")]12 pub legacy_imports: bool,13}1415fn legacy_imports_default() -> bool {16 true17}1819#[derive(Debug, Clone, Serialize, Deserialize)]20pub struct Dependency {21 pub source: Source,22 #[serde(default, skip_serializing_if = "Option::is_none")]23 pub version: Option<String>,24 #[serde(default, skip_serializing_if = "Option::is_none")]25 pub sum: Option<String>,26 #[serde(default, skip_serializing_if = "Option::is_none")]27 pub name: Option<String>,28 #[serde(default, skip_serializing_if = "is_false")]29 pub single: bool,30}3132#[allow(clippy::trivially_copy_pass_by_ref, reason = "serde")]33fn is_false(v: &bool) -> bool {34 !v35}3637#[derive(Debug, Clone, Serialize, Deserialize)]38#[serde(rename_all = "lowercase")]39pub enum Source {40 Git(GitSource),41 Local(LocalSource),42}4344#[derive(Debug, Clone, PartialEq, Eq)]45pub enum GitScheme {46 Https,47 Ssh,48}49505152#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]53pub struct SubDir(Utf8PathBuf);5455#[derive(Debug, thiserror::Error)]56#[error("subdir attempted to escape")]57pub struct SubDirEscapeError;5859impl FromStr for SubDir {60 type Err = SubDirEscapeError;61 fn from_str(s: &str) -> Result<Self, Self::Err> {62 Self::try_from(Utf8PathBuf::from(s))63 }64}65impl TryFrom<Utf8PathBuf> for SubDir {66 type Error = SubDirEscapeError;6768 fn try_from(buf: Utf8PathBuf) -> Result<Self, Self::Error> {69 for ele in buf.components() {70 match ele {71 Utf8Component::Prefix(_) | Utf8Component::RootDir | Utf8Component::ParentDir => {72 return Err(SubDirEscapeError);73 }74 Utf8Component::CurDir | Utf8Component::Normal(_) => {}75 }76 }77 Ok(Self(buf))78 }79}8081impl SubDir {82 pub fn empty() -> Self {83 Self(Utf8PathBuf::new())84 }85 pub fn as_str(&self) -> &str {86 self.0.as_str()87 }88 pub fn as_path(&self) -> &Utf8Path {89 &self.090 }91 pub fn into_inner(self) -> Utf8PathBuf {92 self.093 }94 pub fn join(&self, other: impl AsRef<Utf8Path>) -> Result<SubDir, SubDirEscapeError> {95 SubDir::try_from(self.0.join(other))96 }97 pub fn strip_prefix(&self, prefix: &SubDir) -> Option<SubDir> {98 Some(99 SubDir::try_from(self.0.strip_prefix(&prefix.0).ok()?.to_owned())100 .expect("stripping would not result in escape"),101 )102 }103 pub fn is_empty(&self) -> bool {104 self.0.as_str().is_empty()105 }106 pub fn file_name(&self) -> Option<&str> {107 self.0.file_name()108 }109 110 #[must_use]111 pub fn without_git_suffix(&self) -> SubDir {112 let mut p = self.0.clone();113 if p.extension() == Some("git") {114 p.set_extension("");115 }116 SubDir(p)117 }118}119impl AsRef<Utf8Path> for SubDir {120 fn as_ref(&self) -> &Utf8Path {121 &self.0122 }123}124impl AsRef<Path> for SubDir {125 fn as_ref(&self) -> &Path {126 self.0.as_ref()127 }128}129impl AsRef<str> for SubDir {130 fn as_ref(&self) -> &str {131 self.0.as_str()132 }133}134impl fmt::Display for SubDir {135 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {136 write!(f, "{}", self.0)137 }138}139impl PartialEq<str> for SubDir {140 fn eq(&self, other: &str) -> bool {141 self.0.as_str() == other142 }143}144impl PartialEq<&str> for SubDir {145 fn eq(&self, other: &&str) -> bool {146 self.0.as_str() == *other147 }148}149150151152#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]153pub struct Hostname(String);154155#[derive(Debug, thiserror::Error)]156#[error("invalid hostname")]157pub struct InvalidHostnameError;158159impl FromStr for Hostname {160 type Err = InvalidHostnameError;161162 fn from_str(s: &str) -> Result<Self, Self::Err> {163 if s.is_empty() || s == "." || s == ".." {164 return Err(InvalidHostnameError);165 }166 for seg in s.split('.') {167 if seg.is_empty() {168 return Err(InvalidHostnameError);169 }170 if !seg.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {171 return Err(InvalidHostnameError);172 }173 }174 Ok(Self(s.to_owned()))175 }176}177178impl Hostname {179 pub fn as_str(&self) -> &str {180 &self.0181 }182}183impl AsRef<str> for Hostname {184 fn as_ref(&self) -> &str {185 &self.0186 }187}188impl AsRef<Path> for Hostname {189 fn as_ref(&self) -> &Path {190 self.0.as_ref()191 }192}193impl AsRef<Utf8Path> for Hostname {194 fn as_ref(&self) -> &Utf8Path {195 self.0.as_str().into()196 }197}198impl fmt::Display for Hostname {199 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {200 f.write_str(&self.0)201 }202}203impl PartialEq<str> for Hostname {204 fn eq(&self, other: &str) -> bool {205 self.0 == other206 }207}208impl PartialEq<&str> for Hostname {209 fn eq(&self, other: &&str) -> bool {210 self.0 == *other211 }212}213214#[derive(Debug, Clone, PartialEq, Eq)]215pub struct GitSource {216 pub scheme: GitScheme,217 pub host: Hostname,218 219 pub repo: SubDir,220 221 pub subdir: SubDir,222}223224225226227228229#[derive(Debug, Clone, PartialEq, Eq)]230pub struct LocalSource {231 pub ups: usize,232 pub dir: SubDir,233}234235impl FromStr for LocalSource {236 237 type Err = SubDirEscapeError;238239 fn from_str(s: &str) -> Result<Self, Self::Err> {240 let mut ups = 0usize;241 let mut rest = s;242 loop {243 if let Some(r) = rest.strip_prefix("./") {244 rest = r;245 } else if rest == "." {246 rest = "";247 break;248 } else if let Some(r) = rest.strip_prefix("../") {249 ups = ups.checked_add(1).expect("can't be longer than s length");250 rest = r;251 } else if rest == ".." {252 ups = ups.checked_add(1).expect("can't be longer than s length");253 rest = "";254 break;255 } else {256 break;257 }258 }259 Ok(Self {260 ups,261 dir: SubDir::from_str(rest)?,262 })263 }264}265266impl fmt::Display for LocalSource {267 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {268 let mut out = String::with_capacity(self.ups * 3 + self.dir.as_str().len());269 for _ in 0..self.ups {270 out.push_str("../");271 }272 out.push_str(self.dir.as_str());273 if out.is_empty() {274 out.push('.');275 } else if out.ends_with('/') {276 out.pop();277 }278 279 f.write_str(&out)280 }281}282283impl LocalSource {284 pub fn resolve_under(&self, parent: &SubDir) -> Result<SubDir, SubDirEscapeError> {285 let mut comps: Vec<&str> = parent.as_path().components().map(|c| c.as_str()).collect();286 if self.ups > comps.len() {287 return Err(SubDirEscapeError);288 }289 comps.truncate(comps.len() - self.ups);290 let mut buf = Utf8PathBuf::from_iter(comps);291 buf.push(self.dir.as_path());292 SubDir::try_from(buf)293 }294}295296impl Serialize for LocalSource {297 fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {298 #[derive(Serialize)]299 struct JsonLocal<'a> {300 directory: &'a str,301 }302 let rendered = self.to_string();303 JsonLocal {304 directory: &rendered,305 }306 .serialize(ser)307 }308}309310impl<'de> Deserialize<'de> for LocalSource {311 fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {312 #[derive(Deserialize)]313 struct JsonLocal {314 directory: String,315 }316 let j = JsonLocal::deserialize(de)?;317 LocalSource::from_str(&j.directory)318 .map_err(|e| de::Error::custom(format!("invalid local path {:?}: {e}", j.directory)))319 }320}321322impl GitSource {323 324 pub fn plain_repo_name(&self) -> SubDir {325 self.repo.without_git_suffix()326 }327328 329 pub fn name(&self) -> SubDir {330 let mut p = Utf8PathBuf::from(self.host.as_str());331 p.push(self.plain_repo_name());332 if !self.subdir.is_empty() {333 p.push(self.subdir.as_path());334 }335 SubDir::try_from(p).expect("host + subdirs is a valid SubDir")336 }337338 339 pub fn legacy_name(&self) -> String {340 self.name()341 .file_name()342 .expect("name has at least one component")343 .to_owned()344 }345346 347 pub fn remote(&self) -> String {348 let host = self.host.as_str();349 let repo = self.repo.as_str();350 match self.scheme {351 GitScheme::Ssh => format!("ssh://git@{host}/{repo}"),352 GitScheme::Https => format!("https://{host}/{repo}"),353 }354 }355356 357 358 pub fn parse(uri: &str) -> Option<Dependency> {359 git_uri::parse(uri).ok()360 }361}362363peg::parser! {364 grammar git_uri() for str {365 rule host_segment() = ['a'..='z' | 'A'..='Z' | '0'..='9' | '-']+;366 rule host() -> Hostname367 = s:$(host_segment()++".")368 { Hostname::from_str(s).expect("grammar restricted to valid host chars") }369370 371 rule path_segment() = ['a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-' | '~']+;372 373 rule subdir_segment() = ['a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-' | '.']+;374375 376 rule repo_dotgit() -> SubDir377 = s:$(path_segment()++"/" ".git")378 { SubDir::from_str(s).expect("grammar restricted to subpath chars") }379 380 rule repo_simple() -> SubDir381 = s:$(path_segment() "/" path_segment())382 { SubDir::from_str(s).expect("grammar restricted to subpath chars") }383384 385 rule subdir() -> SubDir386 = "/" s:$(subdir_segment() ** "/") "/"?387 { SubDir::from_str(s).expect("grammar restricted to subdir chars") }388 / { SubDir::empty() }389390 rule version() -> &'input str391 = "@" v:$([_]+) { v }392393394 395 rule scp_uri() -> Dependency396 = "git@" h:host() ":" repo:repo_dotgit() subdir:subdir()397 v:version()?398 {399 make_dep(GitScheme::Ssh, h, repo, subdir, v)400 }401402 403 rule ssh_uri() -> Dependency404 = "ssh://git@" h:host() "/" repo:repo_dotgit() subdir:subdir()405 v:version()?406 {407 make_dep(GitScheme::Ssh, h, repo, subdir, v)408 }409410 411 rule https_dotgit() -> Dependency412 = "https://"? h:host() "/" repo:repo_dotgit() subdir:subdir()413 v:version()?414 {415 make_dep(GitScheme::Https, h, repo, subdir, v)416 }417418 419 rule https_simple() -> Dependency420 = "https://"? h:host() "/" repo:repo_simple() subdir:subdir()421 v:version()?422 {423 make_dep(GitScheme::Https, h, repo, subdir, v)424 }425426 pub rule parse() -> Dependency427 = ssh_uri() / scp_uri() / https_dotgit() / https_simple()428 }429}430431fn make_dep(432 scheme: GitScheme,433 host: Hostname,434 repo: SubDir,435 subdir: SubDir,436 version: Option<&str>,437) -> Dependency {438 Dependency {439 source: Source::Git(GitSource {440 scheme,441 host,442 repo,443 subdir,444 }),445 version: version.map(str::to_owned),446 sum: None,447 name: None,448 single: false,449 }450}451452impl Dependency {453 454 pub fn canonical_name(&self) -> Utf8PathBuf {455 match &self.source {456 Source::Git(git) => git.name().into_inner(),457 Source::Local(local) => Utf8PathBuf::from(local.to_string()),458 }459 }460461 462 pub fn legacy_link_name(&self) -> String {463 if let Some(name) = &self.name {464 return name.clone();465 }466 match &self.source {467 Source::Git(git) => git.legacy_name(),468 Source::Local(local) => local469 .dir470 .file_name()471 .map_or_else(|| local.to_string(), str::to_owned),472 }473 }474}475476impl Serialize for GitSource {477 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {478 #[derive(Serialize)]479 struct JsonGit<'a> {480 remote: String,481 #[serde(skip_serializing_if = "str::is_empty")]482 subdir: &'a str,483 }484 JsonGit {485 remote: self.remote(),486 subdir: self.subdir.as_str(),487 }488 .serialize(serializer)489 }490}491492impl<'de> Deserialize<'de> for GitSource {493 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {494 #[derive(Deserialize)]495 struct JsonGit {496 remote: String,497 #[serde(default)]498 subdir: String,499 }500 let j = JsonGit::deserialize(deserializer)?;501502 let parsed = GitSource::parse(&j.remote)503 .ok_or_else(|| de::Error::custom(format!("unable to parse git url {:?}", j.remote)))?;504 let Source::Git(mut gs) = parsed.source else {505 unreachable!()506 };507508 if !j.subdir.is_empty() {509 gs.subdir = SubDir::from_str(j.subdir.trim_start_matches('/'))510 .map_err(|e| de::Error::custom(format!("invalid subdir {:?}: {e}", j.subdir)))?;511 }512513 Ok(gs)514 }515}516517impl JsonnetFile {518 pub fn load(path: &Path) -> Result<Self, Error> {519 let data = std::fs::read(path).map_err(|e| Error::Io(path.to_owned(), e))?;520 serde_json::from_slice(&data).map_err(Error::Json)521 }522}523524#[derive(Debug)]525pub enum Error {526 Io(std::path::PathBuf, std::io::Error),527 Json(serde_json::Error),528}529impl std::fmt::Display for Error {530 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {531 match self {532 Error::Io(path, e) => write!(f, "{}: {e}", path.display()),533 Error::Json(e) => write!(f, "{e}"),534 }535 }536}537impl std::error::Error for Error {}538539#[cfg(test)]540mod tests {541 use super::*;542543 fn host(s: &str) -> Hostname {544 Hostname::from_str(s).expect("test host")545 }546 fn sd(s: &str) -> SubDir {547 SubDir::from_str(s).expect("test subdir")548 }549550 #[test]551 fn parse_basic() {552 let input = r#"{553 "version": 1,554 "dependencies": [555 {556 "source": {557 "git": {558 "remote": "https://github.com/grafana/jsonnet-libs.git",559 "subdir": "grafana-builder"560 }561 },562 "version": "54865853ebc1f901964e25a2e7a0e4d2cb6b9648",563 "sum": "ELsYwK+kGdzX1mee2Yy+/b2mdO4Y503BOCDkFzwmGbE="564 }565 ],566 "legacyImports": false567 }"#;568569 let jf: JsonnetFile = serde_json::from_str(input).unwrap();570 assert_eq!(jf.version, 1);571 assert!(!jf.legacy_imports);572 assert_eq!(jf.dependencies.len(), 1);573574 let dep = &jf.dependencies[0];575 let Source::Git(git) = &dep.source else {576 panic!("expected git source");577 };578 assert_eq!(git.host, "github.com");579 assert_eq!(git.repo, "grafana/jsonnet-libs.git");580 assert_eq!(git.subdir, "grafana-builder");581 assert_eq!(582 git.name(),583 "github.com/grafana/jsonnet-libs/grafana-builder"584 );585 assert_eq!(git.legacy_name(), "grafana-builder");586 assert_eq!(git.remote(), "https://github.com/grafana/jsonnet-libs.git");587 assert_eq!(588 dep.version.as_deref(),589 Some("54865853ebc1f901964e25a2e7a0e4d2cb6b9648")590 );591 }592593 #[test]594 fn parse_local_source() {595 let input = r#"{596 "version": 1,597 "dependencies": [598 {599 "source": {600 "local": { "directory": "../shared-lib" }601 },602 "version": ""603 }604 ]605 }"#;606607 let jf: JsonnetFile = serde_json::from_str(input).unwrap();608 let dep = &jf.dependencies[0];609 let Source::Local(local) = &dep.source else {610 panic!("expected local source");611 };612 assert_eq!(local.ups, 1);613 assert_eq!(local.dir, "shared-lib");614 assert_eq!(local.to_string(), "../shared-lib");615 assert!(jf.legacy_imports);616 }617618 #[test]619 fn parse_uri_github_slug() {620 let dep = GitSource::parse("github.com/ksonnet/ksonnet-lib/ksonnet.beta.3").unwrap();621 let Source::Git(gs) = &dep.source else {622 panic!()623 };624 assert_eq!(gs.scheme, GitScheme::Https);625 assert_eq!(gs.host, "github.com");626 assert_eq!(gs.repo, "ksonnet/ksonnet-lib");627 assert_eq!(gs.subdir, "ksonnet.beta.3");628 assert_eq!(dep.version, None);629 assert_eq!(gs.remote(), "https://github.com/ksonnet/ksonnet-lib");630 }631632 #[test]633 fn parse_uri_ssh() {634 let dep = GitSource::parse("ssh://git@example.com/user/repo.git/foobar@v1").unwrap();635 let Source::Git(gs) = &dep.source else {636 panic!()637 };638 assert_eq!(gs.scheme, GitScheme::Ssh);639 assert_eq!(gs.host, "example.com");640 assert_eq!(gs.repo, "user/repo.git");641 assert_eq!(gs.subdir, "foobar");642 assert_eq!(dep.version.as_deref(), Some("v1"));643 assert_eq!(gs.remote(), "ssh://git@example.com/user/repo.git");644 }645646 #[test]647 fn parse_uri_scp() {648 let dep = GitSource::parse("git@my.host:user/repo.git/foobar@v1").unwrap();649 let Source::Git(gs) = &dep.source else {650 panic!()651 };652 assert_eq!(gs.scheme, GitScheme::Ssh);653 assert_eq!(gs.host, "my.host");654 assert_eq!(gs.subdir, "foobar");655 assert_eq!(dep.version.as_deref(), Some("v1"));656 assert_eq!(gs.remote(), "ssh://git@my.host/user/repo.git");657 }658659 #[test]660 fn parse_uri_https_explicit() {661 let dep = GitSource::parse("https://example.com/foo/bar").unwrap();662 let Source::Git(gs) = &dep.source else {663 panic!()664 };665 assert_eq!(gs.scheme, GitScheme::Https);666 assert_eq!(gs.host, "example.com");667 assert_eq!(gs.repo, "foo/bar");668 assert_eq!(gs.subdir, "");669 assert_eq!(gs.remote(), "https://example.com/foo/bar");670 }671672 #[test]673 fn parse_uri_no_scheme() {674 let dep = GitSource::parse("example.com/foo/bar").unwrap();675 let Source::Git(gs) = &dep.source else {676 panic!()677 };678 assert_eq!(gs.scheme, GitScheme::Https);679 assert_eq!(gs.host, "example.com");680 assert_eq!(gs.remote(), "https://example.com/foo/bar");681 }682683 #[test]684 fn parse_uri_path_and_version() {685 let dep = GitSource::parse("example.com/foo/bar/baz@bat").unwrap();686 let Source::Git(gs) = &dep.source else {687 panic!()688 };689 assert_eq!(gs.repo, "foo/bar");690 assert_eq!(gs.subdir, "baz");691 assert_eq!(dep.version.as_deref(), Some("bat"));692 }693694 #[test]695 fn parse_uri_version_only() {696 let dep = GitSource::parse("example.com/foo/bar@baz").unwrap();697 let Source::Git(gs) = &dep.source else {698 panic!()699 };700 assert_eq!(gs.repo, "foo/bar");701 assert_eq!(gs.subdir, "");702 assert_eq!(dep.version.as_deref(), Some("baz"));703 }704705 #[test]706 fn parse_uri_deep_path() {707 let dep = GitSource::parse("example.com/foo/bar/baz/bat").unwrap();708 let Source::Git(gs) = &dep.source else {709 panic!()710 };711 assert_eq!(gs.repo, "foo/bar");712 assert_eq!(gs.subdir, "baz/bat");713 }714715 #[test]716 fn parse_uri_subgroups() {717 let dep = GitSource::parse("example.com/group/subgroup/repository.git").unwrap();718 let Source::Git(gs) = &dep.source else {719 panic!()720 };721 assert_eq!(gs.repo, "group/subgroup/repository.git");722 assert_eq!(gs.plain_repo_name(), "group/subgroup/repository");723 assert_eq!(gs.subdir, "");724 assert_eq!(725 gs.remote(),726 "https://example.com/group/subgroup/repository.git"727 );728 }729730 #[test]731 fn parse_uri_subgroup_subdir() {732 let dep = GitSource::parse("example.com/group/subgroup/repository.git/subdir").unwrap();733 let Source::Git(gs) = &dep.source else {734 panic!()735 };736 assert_eq!(gs.plain_repo_name(), "group/subgroup/repository");737 assert_eq!(gs.subdir, "subdir");738 }739740 #[test]741 fn parse_uri_bitbucket_personal() {742 let dep = GitSource::parse("bitbucket.org/~user/repository.git").unwrap();743 let Source::Git(gs) = &dep.source else {744 panic!()745 };746 assert_eq!(gs.host, "bitbucket.org");747 assert_eq!(gs.repo, "~user/repository.git");748 assert_eq!(gs.remote(), "https://bitbucket.org/~user/repository.git");749 }750751 #[test]752 fn name_with_subdir() {753 let gs = GitSource {754 scheme: GitScheme::Https,755 host: host("github.com"),756 repo: sd("ksonnet/ksonnet-lib"),757 subdir: sd("ksonnet.beta.3"),758 };759 assert_eq!(gs.name(), "github.com/ksonnet/ksonnet-lib/ksonnet.beta.3");760 assert_eq!(gs.legacy_name(), "ksonnet.beta.3");761 }762763 #[test]764 fn name_without_subdir() {765 let gs = GitSource {766 scheme: GitScheme::Https,767 host: host("github.com"),768 repo: sd("user/repo"),769 subdir: SubDir::empty(),770 };771 assert_eq!(gs.name(), "github.com/user/repo");772 assert_eq!(gs.legacy_name(), "repo");773 }774775 #[test]776 fn defaults() {777 let input = r#"{ "version": 1 }"#;778 let jf: JsonnetFile = serde_json::from_str(input).unwrap();779 assert!(jf.dependencies.is_empty());780 assert!(jf.legacy_imports);781 }782783 #[test]784 fn roundtrip() {785 let jf = JsonnetFile {786 version: 1,787 dependencies: vec![Dependency {788 source: Source::Git(GitSource {789 scheme: GitScheme::Https,790 host: host("github.com"),791 repo: sd("user/repo"),792 subdir: sd("lib"),793 }),794 version: Some("main".into()),795 sum: None,796 name: None,797 single: false,798 }],799 legacy_imports: false,800 };801 let json = serde_json::to_string_pretty(&jf).unwrap();802 let parsed: JsonnetFile = serde_json::from_str(&json).unwrap();803 assert_eq!(parsed.dependencies.len(), 1);804 let Source::Git(gs) = &parsed.dependencies[0].source else {805 panic!()806 };807 assert_eq!(gs.host, "github.com");808 assert_eq!(gs.repo, "user/repo");809 assert_eq!(gs.subdir, "lib");810 }811812 #[test]813 fn hostname_rejects_slash() {814 assert!(Hostname::from_str("foo/bar").is_err());815 assert!(Hostname::from_str("").is_err());816 assert!(Hostname::from_str(".").is_err());817 assert!(Hostname::from_str("..").is_err());818 assert!(Hostname::from_str(".foo").is_err());819 assert!(Hostname::from_str("foo.").is_err());820 assert!(Hostname::from_str("foo..bar").is_err());821 assert!(Hostname::from_str("foo bar").is_err());822 assert!(Hostname::from_str("foo.bar").is_ok());823 }824825 #[test]826 fn subdir_rejects_escape() {827 assert!(SubDir::from_str("../foo").is_err());828 assert!(SubDir::from_str("/foo").is_err());829 assert!(SubDir::from_str("foo/../bar").is_err());830 assert!(SubDir::from_str("foo/bar").is_ok());831 assert!(SubDir::from_str("").is_ok());832 }833834 #[test]835 fn local_source_parse() {836 let l = LocalSource::from_str("../shared-lib").unwrap();837 assert_eq!(l.ups, 1);838 assert_eq!(l.dir, "shared-lib");839840 let l = LocalSource::from_str("../../foo/bar").unwrap();841 assert_eq!(l.ups, 2);842 assert_eq!(l.dir, "foo/bar");843844 let l = LocalSource::from_str("./foo").unwrap();845 assert_eq!(l.ups, 0);846 assert_eq!(l.dir, "foo");847848 let l = LocalSource::from_str(".").unwrap();849 assert_eq!(l.ups, 0);850 assert!(l.dir.is_empty());851852 let l = LocalSource::from_str("..").unwrap();853 assert_eq!(l.ups, 1);854 assert!(l.dir.is_empty());855856 857 assert!(LocalSource::from_str("foo/../bar").is_err());858 859 assert!(LocalSource::from_str("/foo").is_err());860 }861862 #[test]863 fn local_source_render_roundtrip() {864 for s in ["../shared-lib", "../../foo/bar", "foo", "."] {865 assert_eq!(LocalSource::from_str(s).unwrap().to_string(), s);866 }867 }868869 #[test]870 fn local_source_resolve_under() {871 872 let l = LocalSource::from_str("../foo").unwrap();873 assert_eq!(l.resolve_under(&sd("pkg/sub")).unwrap(), "pkg/foo");874875 876 let l = LocalSource::from_str("foo").unwrap();877 assert_eq!(l.resolve_under(&sd("pkg/sub")).unwrap(), "pkg/sub/foo");878879 880 let l = LocalSource::from_str("../../../foo").unwrap();881 assert!(l.resolve_under(&sd("pkg")).is_err());882 }883}