From f336774748e72116c61bb188e4812a6291670e26 Mon Sep 17 00:00:00 2001 From: Harris Mendell Date: Wed, 27 Sep 2023 10:15:08 -0400 Subject: [PATCH 1/7] Add UserServiceAccountImpersonationMode --- .../gcp/GoogleConfiguration.scala | 5 +++ .../gcp/auth/GoogleAuthMode.scala | 35 ++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/cloudSupport/src/main/scala/cromwell/cloudsupport/gcp/GoogleConfiguration.scala b/cloudSupport/src/main/scala/cromwell/cloudsupport/gcp/GoogleConfiguration.scala index 2b4a183c121..1f3ba102ffb 100644 --- a/cloudSupport/src/main/scala/cromwell/cloudsupport/gcp/GoogleConfiguration.scala +++ b/cloudSupport/src/main/scala/cromwell/cloudsupport/gcp/GoogleConfiguration.scala @@ -82,6 +82,10 @@ object GoogleConfiguration { UserServiceAccountMode(name) } + def userServiceAccountImpersonationAuth(name: String): ErrorOr[GoogleAuthMode] = validate { + UserServiceAccountImpersonationMode(name) + } + val name = authConfig.getString("name") val scheme = authConfig.getString("scheme") scheme match { @@ -89,6 +93,7 @@ object GoogleConfiguration { case "user_account" => userAccountAuth(authConfig, name) case "application_default" => applicationDefaultAuth(name) case "user_service_account" => userServiceAccountAuth(name) + case "user_service_account_impersonation" => userServiceAccountImpersonationAuth(name) case "mock" => MockAuthMode(name).validNel case wut => s"Unsupported authentication scheme: $wut".invalidNel } diff --git a/cloudSupport/src/main/scala/cromwell/cloudsupport/gcp/auth/GoogleAuthMode.scala b/cloudSupport/src/main/scala/cromwell/cloudsupport/gcp/auth/GoogleAuthMode.scala index e850b53807a..e28995df881 100644 --- a/cloudSupport/src/main/scala/cromwell/cloudsupport/gcp/auth/GoogleAuthMode.scala +++ b/cloudSupport/src/main/scala/cromwell/cloudsupport/gcp/auth/GoogleAuthMode.scala @@ -10,10 +10,11 @@ import com.google.api.client.http.{HttpResponseException, HttpTransport} import com.google.api.client.json.gson.GsonFactory import com.google.auth.Credentials import com.google.auth.http.HttpTransportFactory -import com.google.auth.oauth2.{GoogleCredentials, OAuth2Credentials, ServiceAccountCredentials, UserCredentials} +import com.google.auth.oauth2.{GoogleCredentials, OAuth2Credentials, ServiceAccountCredentials, UserCredentials, ImpersonatedCredentials} import com.google.cloud.NoCredentials import com.typesafe.scalalogging.LazyLogging import cromwell.cloudsupport.gcp.auth.ApplicationDefaultMode.applicationDefaultCredentials +import cromwell.cloudsupport.gcp.auth.UserServiceAccountImpersonationMode.applicationDefaultCredentials import cromwell.cloudsupport.gcp.auth.GoogleAuthMode._ import cromwell.cloudsupport.gcp.auth.ServiceAccountMode.{CredentialFileFormat, JsonFileFormat, PemFileFormat} @@ -43,6 +44,7 @@ object GoogleAuthMode { lazy val HttpTransportFactory: HttpTransportFactory = () => httpTransport val UserServiceAccountKey = "user_service_account_json" + val UserServiceAccountEmailKey = "user_service_account_email" val DockerCredentialsEncryptionKeyNameKey = "docker_credentials_key_name" val DockerCredentialsTokenKey = "docker_credentials_token" @@ -206,6 +208,37 @@ final case class ApplicationDefaultMode(name: String) extends GoogleAuthMode { } } +object UserServiceAccountImpersonationMode { + private lazy val applicationDefaultCredentials: GoogleCredentials = GoogleCredentials.getApplicationDefault +} + +final case class UserServiceAccountImpersonationMode(name: String) extends GoogleAuthMode { + + private def extractServiceAccount(options: OptionLookup): String = { + extract(options, UserServiceAccountEmailKey) + } + + override def validateCredentials[A <: GoogleCredentials](credential: A, + scopes: Iterable[String]): GoogleCredentials = { + Try(credentialsValidation(credential)) match { + case Failure(ex) => throw new RuntimeException(s"Google credentials are invalid: ${ex.getMessage}", ex) + case Success(_) => credential + } + } + + override def credentials(options: OptionLookup, + scopes: Iterable[String]): GoogleCredentials = { + val credentials = ImpersonatedCredentials.create( + applicationDefaultCredentials, + extractServiceAccount(options), + null, + new java.util.ArrayList[String](scopes.toList.asJava), + 3600 + ) + validateCredentials(credentials, scopes) + } +} + sealed trait ClientSecrets { val clientId: String val clientSecret: String From 941494058f587b0d273af893694404aa69e66da2 Mon Sep 17 00:00:00 2001 From: Harris Mendell Date: Wed, 27 Sep 2023 10:39:17 -0400 Subject: [PATCH 2/7] Add unit tests and override isEmpty --- .../main/scala/cloud/nio/spi/UnixPath.scala | 2 +- .../gcp/auth/GoogleAuthModeSpec.scala | 1 + ...rServiceAccountImpersonationModeSpec.scala | 24 +++++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 cloudSupport/src/test/scala/cromwell/cloudsupport/gcp/auth/UserServiceAccountImpersonationModeSpec.scala diff --git a/cloud-nio/cloud-nio-spi/src/main/scala/cloud/nio/spi/UnixPath.scala b/cloud-nio/cloud-nio-spi/src/main/scala/cloud/nio/spi/UnixPath.scala index fb3a2bf8f50..7fb8f38c098 100644 --- a/cloud-nio/cloud-nio-spi/src/main/scala/cloud/nio/spi/UnixPath.scala +++ b/cloud-nio/cloud-nio-spi/src/main/scala/cloud/nio/spi/UnixPath.scala @@ -69,7 +69,7 @@ final private[spi] case class UnixPath(path: String) extends CharSequence { def isAbsolute: Boolean = UnixPath.isAbsolute(path) - def isEmpty: Boolean = path.isEmpty + override def isEmpty: Boolean = path.isEmpty def hasTrailingSeparator: Boolean = UnixPath.hasTrailingSeparator(path) diff --git a/cloudSupport/src/test/scala/cromwell/cloudsupport/gcp/auth/GoogleAuthModeSpec.scala b/cloudSupport/src/test/scala/cromwell/cloudsupport/gcp/auth/GoogleAuthModeSpec.scala index 368ce5d2472..5d078bdbdd8 100644 --- a/cloudSupport/src/test/scala/cromwell/cloudsupport/gcp/auth/GoogleAuthModeSpec.scala +++ b/cloudSupport/src/test/scala/cromwell/cloudsupport/gcp/auth/GoogleAuthModeSpec.scala @@ -66,4 +66,5 @@ object GoogleAuthModeSpec extends ServiceAccountTestSupport { lazy val refreshTokenOptions: OptionLookup = Map("refresh_token" -> "the_refresh_token") lazy val userServiceAccountOptions: OptionLookup = Map("user_service_account_json" -> serviceAccountJsonContents) + lazy val userServiceAccountImpersonationOptions: OptionLookup = Map("user_service_account_email" -> "the email") } diff --git a/cloudSupport/src/test/scala/cromwell/cloudsupport/gcp/auth/UserServiceAccountImpersonationModeSpec.scala b/cloudSupport/src/test/scala/cromwell/cloudsupport/gcp/auth/UserServiceAccountImpersonationModeSpec.scala new file mode 100644 index 00000000000..4d897d0db29 --- /dev/null +++ b/cloudSupport/src/test/scala/cromwell/cloudsupport/gcp/auth/UserServiceAccountImpersonationModeSpec.scala @@ -0,0 +1,24 @@ +package cromwell.cloudsupport.gcp.auth + +import common.assertion.CromwellTimeoutSpec +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class UserServiceAccountImpersonationModeSpec extends AnyFlatSpec with CromwellTimeoutSpec with Matchers { + + behavior of "UserServiceAccountImpersonationMode" + + it should "generate a non-validated credential" in { + val impersonationMode = UserServiceAccountImpersonationMode("user-service-account-impersonation") + val workflowOptions = GoogleAuthModeSpec.userServiceAccountImpersonationOptions + impersonationMode.credentialsValidation = GoogleAuthMode.NoCredentialsValidation + val credentials = impersonationMode.credentials(workflowOptions) + credentials.getAuthenticationType should be("OAuth2") + } + + it should "fail to generate credentials without a user_service_account_email workflow option" in { + val impersonationMode = UserServiceAccountImpersonationMode("user-service-account-impersonation") + val exception = intercept[OptionLookupException](impersonationMode.credentials()) + exception.getMessage should be("user_service_account_email") + } +} From 4859f144318d09fb8e482f13bfaebdba216dc523 Mon Sep 17 00:00:00 2001 From: Harris Mendell Date: Wed, 27 Sep 2023 10:51:44 -0400 Subject: [PATCH 3/7] Update validateCredentials to optionally take scopes instead of require --- .../gcp/auth/GoogleAuthMode.scala | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/cloudSupport/src/main/scala/cromwell/cloudsupport/gcp/auth/GoogleAuthMode.scala b/cloudSupport/src/main/scala/cromwell/cloudsupport/gcp/auth/GoogleAuthMode.scala index e28995df881..972e6b3c13b 100644 --- a/cloudSupport/src/main/scala/cromwell/cloudsupport/gcp/auth/GoogleAuthMode.scala +++ b/cloudSupport/src/main/scala/cromwell/cloudsupport/gcp/auth/GoogleAuthMode.scala @@ -117,12 +117,18 @@ sealed trait GoogleAuthMode extends LazyLogging { */ private[auth] var credentialsValidation: CredentialsValidation = refreshCredentials - protected def validateCredentials[A <: GoogleCredentials](credential: A, - scopes: Iterable[String]): GoogleCredentials = { - val scopedCredentials = credential.createScoped(scopes.asJavaCollection) - Try(credentialsValidation(scopedCredentials)) match { - case Failure(ex) => throw new RuntimeException(s"Google credentials are invalid: ${ex.getMessage}", ex) - case Success(_) => scopedCredentials + protected def validateCredentials[A <: GoogleCredentials]( + credential: A, + scopes: Iterable[String] + ): GoogleCredentials = { + val credentialsToValidate = + if (scopes != null) credential.createScoped(scopes.asJavaCollection) + else credential + Try(credentialsValidation(credentialsToValidate)) match { + case Failure(ex) => + throw new RuntimeException(s"Google credentials are invalid: ${ex.getMessage}", ex) + case Success(_) => + credentialsToValidate } } } @@ -218,24 +224,18 @@ final case class UserServiceAccountImpersonationMode(name: String) extends Googl extract(options, UserServiceAccountEmailKey) } - override def validateCredentials[A <: GoogleCredentials](credential: A, - scopes: Iterable[String]): GoogleCredentials = { - Try(credentialsValidation(credential)) match { - case Failure(ex) => throw new RuntimeException(s"Google credentials are invalid: ${ex.getMessage}", ex) - case Success(_) => credential - } - } - override def credentials(options: OptionLookup, scopes: Iterable[String]): GoogleCredentials = { val credentials = ImpersonatedCredentials.create( applicationDefaultCredentials, extractServiceAccount(options), null, - new java.util.ArrayList[String](scopes.toList.asJava), + scopes.toList.asJava, 3600 ) - validateCredentials(credentials, scopes) + // We don't pass in scopes because they are added to the credentials + // when we create ImpersonatedCredentials above. + validateCredentials(credentials, null) } } From 0892600f2b4e4328375212f01212f6637fc2c399 Mon Sep 17 00:00:00 2001 From: Harris Mendell Date: Wed, 27 Sep 2023 11:23:17 -0400 Subject: [PATCH 4/7] add support for source credentials coming from json file" --- .../gcp/GoogleConfiguration.scala | 9 ++-- .../gcp/auth/GoogleAuthMode.scala | 52 ++++++++++++------- 2 files changed, 38 insertions(+), 23 deletions(-) diff --git a/cloudSupport/src/main/scala/cromwell/cloudsupport/gcp/GoogleConfiguration.scala b/cloudSupport/src/main/scala/cromwell/cloudsupport/gcp/GoogleConfiguration.scala index 1f3ba102ffb..f3fbb5725a0 100644 --- a/cloudSupport/src/main/scala/cromwell/cloudsupport/gcp/GoogleConfiguration.scala +++ b/cloudSupport/src/main/scala/cromwell/cloudsupport/gcp/GoogleConfiguration.scala @@ -82,8 +82,11 @@ object GoogleConfiguration { UserServiceAccountMode(name) } - def userServiceAccountImpersonationAuth(name: String): ErrorOr[GoogleAuthMode] = validate { - UserServiceAccountImpersonationMode(name) + def userServiceAccountImpersonationAuth(authConfig: Config, name: String): ErrorOr[GoogleAuthMode] = validate { + authConfig.getAs[String]("json-file") match { + case Some(json) => UserServiceAccountImpersonationMode(name, JsonFileFormat(json)) + case None => UserServiceAccountImpersonationMode(name) + } } val name = authConfig.getString("name") @@ -93,7 +96,7 @@ object GoogleConfiguration { case "user_account" => userAccountAuth(authConfig, name) case "application_default" => applicationDefaultAuth(name) case "user_service_account" => userServiceAccountAuth(name) - case "user_service_account_impersonation" => userServiceAccountImpersonationAuth(name) + case "user_service_account_impersonation" => userServiceAccountImpersonationAuth(authConfig, name) case "mock" => MockAuthMode(name).validNel case wut => s"Unsupported authentication scheme: $wut".invalidNel } diff --git a/cloudSupport/src/main/scala/cromwell/cloudsupport/gcp/auth/GoogleAuthMode.scala b/cloudSupport/src/main/scala/cromwell/cloudsupport/gcp/auth/GoogleAuthMode.scala index 972e6b3c13b..99a4ac4b56d 100644 --- a/cloudSupport/src/main/scala/cromwell/cloudsupport/gcp/auth/GoogleAuthMode.scala +++ b/cloudSupport/src/main/scala/cromwell/cloudsupport/gcp/auth/GoogleAuthMode.scala @@ -3,18 +3,16 @@ package cromwell.cloudsupport.gcp.auth import java.io.{ByteArrayInputStream, FileNotFoundException, InputStream} import java.net.HttpURLConnection._ import java.nio.charset.StandardCharsets - import better.files.File import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport import com.google.api.client.http.{HttpResponseException, HttpTransport} import com.google.api.client.json.gson.GsonFactory import com.google.auth.Credentials import com.google.auth.http.HttpTransportFactory -import com.google.auth.oauth2.{GoogleCredentials, OAuth2Credentials, ServiceAccountCredentials, UserCredentials, ImpersonatedCredentials} +import com.google.auth.oauth2.{GoogleCredentials, ImpersonatedCredentials, OAuth2Credentials, ServiceAccountCredentials, UserCredentials} import com.google.cloud.NoCredentials import com.typesafe.scalalogging.LazyLogging import cromwell.cloudsupport.gcp.auth.ApplicationDefaultMode.applicationDefaultCredentials -import cromwell.cloudsupport.gcp.auth.UserServiceAccountImpersonationMode.applicationDefaultCredentials import cromwell.cloudsupport.gcp.auth.GoogleAuthMode._ import cromwell.cloudsupport.gcp.auth.ServiceAccountMode.{CredentialFileFormat, JsonFileFormat, PemFileFormat} @@ -75,6 +73,18 @@ object GoogleAuthMode { private def refreshCredentials(credentials: Credentials): Unit = { credentials.refresh() } + + def createServiceAccountCredentials(fileFormat: CredentialFileFormat): ServiceAccountCredentials = { + val credentialsFile = File(fileFormat.file) + checkReadable(credentialsFile) + + fileFormat match { + case PemFileFormat(accountId, _) => + ServiceAccountCredentials.fromPkcs8(accountId, accountId, credentialsFile.contentAsString, null, null) + case _: JsonFileFormat => ServiceAccountCredentials.fromStream(credentialsFile.newInputStream) + } + } + } sealed trait GoogleAuthMode extends LazyLogging { @@ -157,14 +167,10 @@ final case class ServiceAccountMode(override val name: String, private val credentialsFile = File(fileFormat.file) checkReadable(credentialsFile) - private lazy val serviceAccountCredentials: ServiceAccountCredentials = { - fileFormat match { - case PemFileFormat(accountId, _) => - logger.warn("The PEM file format will be deprecated in the upcoming Cromwell version. Please use JSON instead.") - ServiceAccountCredentials.fromPkcs8(accountId, accountId, credentialsFile.contentAsString, null, null) - case _: JsonFileFormat => ServiceAccountCredentials.fromStream(credentialsFile.newInputStream) - } + if (fileFormat.isInstanceOf[PemFileFormat]) { + logger.warn("The PEM file format will be deprecated in the upcoming Cromwell version. Please use JSON instead.") } + private lazy val serviceAccountCredentials: ServiceAccountCredentials = createServiceAccountCredentials(fileFormat) override def credentials(unusedOptions: OptionLookup, scopes: Iterable[String]): GoogleCredentials = { @@ -214,28 +220,34 @@ final case class ApplicationDefaultMode(name: String) extends GoogleAuthMode { } } -object UserServiceAccountImpersonationMode { - private lazy val applicationDefaultCredentials: GoogleCredentials = GoogleCredentials.getApplicationDefault -} - -final case class UserServiceAccountImpersonationMode(name: String) extends GoogleAuthMode { +final case class UserServiceAccountImpersonationMode( + override val name: String, + jsonFileFormat: Option[JsonFileFormat] = None // Optional credential file format +) extends GoogleAuthMode { private def extractServiceAccount(options: OptionLookup): String = { extract(options, UserServiceAccountEmailKey) } - override def credentials(options: OptionLookup, - scopes: Iterable[String]): GoogleCredentials = { - val credentials = ImpersonatedCredentials.create( - applicationDefaultCredentials, + override def credentials(options: OptionLookup, scopes: Iterable[String]): GoogleCredentials = { + // Credentials for the source service account that should have + // roles/iam.serviceAccountTokenCreator on the target service account + val credentials = jsonFileFormat match { + case Some(format) => createServiceAccountCredentials(format) + case None => GoogleCredentials.getApplicationDefault + } + + val impersonatedCredentials = ImpersonatedCredentials.create( + credentials, extractServiceAccount(options), null, scopes.toList.asJava, 3600 ) + // We don't pass in scopes because they are added to the credentials // when we create ImpersonatedCredentials above. - validateCredentials(credentials, null) + validateCredentials(impersonatedCredentials, null) } } From 32f8839503a2bb02f56dd1db957054490d56b333 Mon Sep 17 00:00:00 2001 From: Harris Mendell Date: Wed, 27 Sep 2023 13:42:19 -0400 Subject: [PATCH 5/7] Update how options are sent --- .../cromwell/cloudsupport/gcp/GoogleConfiguration.scala | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/cloudSupport/src/main/scala/cromwell/cloudsupport/gcp/GoogleConfiguration.scala b/cloudSupport/src/main/scala/cromwell/cloudsupport/gcp/GoogleConfiguration.scala index f3fbb5725a0..fd6527a548c 100644 --- a/cloudSupport/src/main/scala/cromwell/cloudsupport/gcp/GoogleConfiguration.scala +++ b/cloudSupport/src/main/scala/cromwell/cloudsupport/gcp/GoogleConfiguration.scala @@ -83,10 +83,8 @@ object GoogleConfiguration { } def userServiceAccountImpersonationAuth(authConfig: Config, name: String): ErrorOr[GoogleAuthMode] = validate { - authConfig.getAs[String]("json-file") match { - case Some(json) => UserServiceAccountImpersonationMode(name, JsonFileFormat(json)) - case None => UserServiceAccountImpersonationMode(name) - } + val jsonFileOpt: Option[JsonFileFormat] = authConfig.getAs[String]("json-file").map(JsonFileFormat) + UserServiceAccountImpersonationMode(name, jsonFileOpt) } val name = authConfig.getString("name") From a26ef90836544a96d134d15276ba140ae7ed5460 Mon Sep 17 00:00:00 2001 From: Harris Mendell Date: Wed, 27 Sep 2023 15:05:30 -0400 Subject: [PATCH 6/7] Add GoogleConfigurationSpec test --- .../gcp/GoogleConfigurationSpec.scala | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/cloudSupport/src/test/scala/cromwell/cloudsupport/gcp/GoogleConfigurationSpec.scala b/cloudSupport/src/test/scala/cromwell/cloudsupport/gcp/GoogleConfigurationSpec.scala index 95a2380034a..124c811df3b 100644 --- a/cloudSupport/src/test/scala/cromwell/cloudsupport/gcp/GoogleConfigurationSpec.scala +++ b/cloudSupport/src/test/scala/cromwell/cloudsupport/gcp/GoogleConfigurationSpec.scala @@ -54,7 +54,16 @@ class GoogleConfigurationSpec extends AnyFlatSpec with CromwellTimeoutSpec with | { | name = "name-user-service-account" | scheme = "user_service_account" - | } + | }, + | { + | name = "name-user-service-account-impersonation" + | scheme = "user_service_account_impersonation" + | }, + | { + | name = "name-user-service-account-impersonation_json" + | scheme = "user_service_account_impersonation" + | json-file = "${jsonMockFile.pathAsString}" + | }, | ] |} | @@ -63,7 +72,7 @@ class GoogleConfigurationSpec extends AnyFlatSpec with CromwellTimeoutSpec with val gconf = GoogleConfiguration(ConfigFactory.parseString(righteousGoogleConfig)) gconf.applicationName shouldBe "cromwell" - gconf.authsByName should have size 5 + gconf.authsByName should have size 7 val auths = gconf.authsByName.values @@ -87,6 +96,17 @@ class GoogleConfigurationSpec extends AnyFlatSpec with CromwellTimeoutSpec with serviceJson.fileFormat.isInstanceOf[JsonFileFormat] shouldBe true serviceJson.fileFormat.file shouldBe jsonMockFile.pathAsString + val serviceImpersonation = (auths collectFirst { case a: UserServiceAccountImpersonationMode if a.name == "name-user-service-account-impersonation" => a }).get + serviceImpersonation.name shouldBe "name-user-service-account-impersonation" + + val serviceImpersonationJson = (auths collectFirst { case a: UserServiceAccountImpersonationMode if a.name == "name-user-service-account-impersonation_json" => a }).get + serviceImpersonationJson.name shouldBe "name-user-service-account-impersonation_json" + serviceImpersonationJson.jsonFileFormat.isInstanceOf[Option[JsonFileFormat]] shouldBe true + serviceImpersonationJson.jsonFileFormat match { + case Some(format) => format.file shouldBe jsonMockFile.pathAsString + case None => fail("jsonFileFormat should be Some") + } + pemMockFile.delete(swallowIOExceptions = true) jsonMockFile.delete(swallowIOExceptions = true) } From ee6758c4903f98014db2d4ee27a0393ab485a77e Mon Sep 17 00:00:00 2001 From: Harris Mendell Date: Wed, 11 Oct 2023 11:46:13 -0400 Subject: [PATCH 7/7] Remove override on isEmpty --- .../cloud-nio-spi/src/main/scala/cloud/nio/spi/UnixPath.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloud-nio/cloud-nio-spi/src/main/scala/cloud/nio/spi/UnixPath.scala b/cloud-nio/cloud-nio-spi/src/main/scala/cloud/nio/spi/UnixPath.scala index 7fb8f38c098..fb3a2bf8f50 100644 --- a/cloud-nio/cloud-nio-spi/src/main/scala/cloud/nio/spi/UnixPath.scala +++ b/cloud-nio/cloud-nio-spi/src/main/scala/cloud/nio/spi/UnixPath.scala @@ -69,7 +69,7 @@ final private[spi] case class UnixPath(path: String) extends CharSequence { def isAbsolute: Boolean = UnixPath.isAbsolute(path) - override def isEmpty: Boolean = path.isEmpty + def isEmpty: Boolean = path.isEmpty def hasTrailingSeparator: Boolean = UnixPath.hasTrailingSeparator(path)