git.delta.rocks / jrsonnet / refs/commits / 6710888e6b7f

difftreelog

source

crates/jrsonnet-pkg/src/jsonnet_bundler.rs22.6 KiBsourcehistory
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}4950/// Wrapper over `Utf8PathBuf`, ensuring it can't escape to either an absolute51/// path or a parent directory.52#[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	/// Strip a trailing `.git` extension, if any.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}149150/// Wrapper over `String`, guaranteeing the value is a valid host: only ASCII151/// alphanumerics, dashes and dots, with at least one segment.152#[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	/// Repo path relative to host: `user/repo[.git]` (or with subgroups).219	pub repo: SubDir,220	/// Subdirectory within the repo. Empty means the repo root.221	pub subdir: SubDir,222}223224/// A relative path that may climb out of its package via `..` parts, but only225/// at the head - once you go down (`SubDir` portion) you can't go back up.226///227/// The total upward count is bounded only at resolution time, against the228/// containing package's depth.229#[derive(Debug, Clone, PartialEq, Eq)]230pub struct LocalSource {231	pub ups: usize,232	pub dir: SubDir,233}234235impl FromStr for LocalSource {236	// Technically incorrect, as it only rejects mid-path ../'s...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		// TODO: I didn't finish279		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	/// Repo path with the trailing `.git` (if any) stripped.324	pub fn plain_repo_name(&self) -> SubDir {325		self.repo.without_git_suffix()326	}327328	/// Canonical install path: `host/user/repo[/subdir]`.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	/// Last path component of `repo[/subdir]`, used as the legacy symlink name.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	/// Git remote URL for cloning.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	/// Parse a URI like `github.com/user/repo/subdir@version` into a357	/// `Dependency`.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		// User/repo path segments. `~` is allowed for Bitbucket personal repos.371		rule path_segment() = ['a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-' | '~']+;372		// Subdir segments allow dots (e.g. `ksonnet.beta.3`).373		rule subdir_segment() = ['a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-' | '.']+;374375		// `user[/group...]/repo.git`376		rule repo_dotgit() -> SubDir377			= s:$(path_segment()++"/" ".git")378			{ SubDir::from_str(s).expect("grammar restricted to subpath chars") }379		// `user/repo` (exactly two segments, no `.git`)380		rule repo_simple() -> SubDir381			= s:$(path_segment() "/" path_segment())382			{ SubDir::from_str(s).expect("grammar restricted to subpath chars") }383384		// Subdir starts with `/`. May be empty.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		// git@host:path.git[/subdir][@version]  (SCP style)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		// ssh://git@host/path.git[/subdir][@version]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		// [https://]host/path.git[/subdir][@version]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		// [https://]host/user/repo[/subdir[/...]][@version]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	/// Canonical install path for deduplication and vendor extraction.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	/// Legacy symlink name: `dep.name` override, or last path component.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		// Mid-path `..` is rejected.857		assert!(LocalSource::from_str("foo/../bar").is_err());858		// Absolute path is rejected.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		// `../foo` from `pkg/sub` lands at `pkg/foo`.872		let l = LocalSource::from_str("../foo").unwrap();873		assert_eq!(l.resolve_under(&sd("pkg/sub")).unwrap(), "pkg/foo");874875		// Plain `foo` from `pkg/sub` lands at `pkg/sub/foo`.876		let l = LocalSource::from_str("foo").unwrap();877		assert_eq!(l.resolve_under(&sd("pkg/sub")).unwrap(), "pkg/sub/foo");878879		// Too many `..` escapes the parent.880		let l = LocalSource::from_str("../../../foo").unwrap();881		assert!(l.resolve_under(&sd("pkg")).is_err());882	}883}