From 403d512c9e1c8c2390419dc26159ab2921290e9f Mon Sep 17 00:00:00 2001 From: mattcasters Date: Thu, 1 Oct 2026 17:50:54 +0200 Subject: [PATCH 1/4] Issue #8671 : Read and write Windows shares through an SMB VFS connection Named SMB2/SMB3 connection over smbj, with NTLMv2 and guest authentication. Kerberos stays a later authenticator on the same metadata type. Integration tests run against Samba in Docker with SMB1 disabled. --- assemblies/plugins/pom.xml | 6 + .../metadata/api/HopMetadataPropertyType.java | 1 + .../pages/plugin-types/data-connectivity.adoc | 2 + docs/hop-user-manual/modules/ROOT/nav.adoc | 2 + .../pages/metadata-types/smb-connection.adoc | 70 +++ .../modules/ROOT/pages/vfs.adoc | 1 + .../modules/ROOT/pages/vfs/smb-vfs.adoc | 58 +++ plugins/tech/pom.xml | 1 + plugins/tech/smb/pom.xml | 104 +++++ plugins/tech/smb/src/assembly/assembly.xml | 37 ++ .../hop/vfs/smb/GuestSmbAuthenticator.java | 30 ++ .../hop/vfs/smb/NtlmSmbAuthenticator.java | 46 ++ .../org/apache/hop/vfs/smb/SmbAuthType.java | 34 ++ .../apache/hop/vfs/smb/SmbAuthenticator.java | 39 ++ .../apache/hop/vfs/smb/SmbAuthenticators.java | 32 ++ .../hop/vfs/smb/SmbConnectionTester.java | 50 +++ .../org/apache/hop/vfs/smb/SmbDialect.java | 45 ++ .../org/apache/hop/vfs/smb/SmbErrors.java | 62 +++ .../org/apache/hop/vfs/smb/SmbFileName.java | 40 ++ .../apache/hop/vfs/smb/SmbFileNameParser.java | 58 +++ .../org/apache/hop/vfs/smb/SmbFileObject.java | 136 ++++++ .../apache/hop/vfs/smb/SmbFileProvider.java | 88 ++++ .../org/apache/hop/vfs/smb/SmbFileSystem.java | 87 ++++ .../org/apache/hop/vfs/smb/SmbIdentity.java | 42 ++ .../java/org/apache/hop/vfs/smb/SmbPaths.java | 79 ++++ .../hop/vfs/smb/SmbRandomAccessContent.java | 140 ++++++ .../org/apache/hop/vfs/smb/SmbSettings.java | 122 +++++ .../java/org/apache/hop/vfs/smb/SmbShare.java | 63 +++ .../org/apache/hop/vfs/smb/SmbVfsPlugin.java | 74 +++ .../org/apache/hop/vfs/smb/SmbjSmbShare.java | 422 ++++++++++++++++++ .../hop/vfs/smb/metadata/SmbConnection.java | 357 +++++++++++++++ .../vfs/smb/metadata/SmbConnectionEditor.java | 175 ++++++++ .../smb/messages/messages_en_US.properties | 22 + .../messages/messages_en_US.properties | 53 +++ plugins/tech/smb/src/main/resources/smb.svg | 9 + .../tech/smb/src/main/resources/version.xml | 20 + plugins/tech/smb/src/test/docker/Dockerfile | 27 ++ .../tech/smb/src/test/docker/entrypoint.sh | 27 ++ plugins/tech/smb/src/test/docker/smb.conf | 39 ++ .../apache/hop/vfs/smb/MemorySmbShare.java | 305 +++++++++++++ .../vfs/smb/SmbConnectionSerializerTest.java | 94 ++++ .../apache/hop/vfs/smb/SmbFileObjectTest.java | 200 +++++++++ .../apache/hop/vfs/smb/SmbSupportTest.java | 184 ++++++++ .../apache/hop/vfs/smb/SmbVfsContainerIT.java | 252 +++++++++++ 44 files changed, 3735 insertions(+) create mode 100644 docs/hop-user-manual/modules/ROOT/pages/metadata-types/smb-connection.adoc create mode 100644 docs/hop-user-manual/modules/ROOT/pages/vfs/smb-vfs.adoc create mode 100644 plugins/tech/smb/pom.xml create mode 100644 plugins/tech/smb/src/assembly/assembly.xml create mode 100644 plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/GuestSmbAuthenticator.java create mode 100644 plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/NtlmSmbAuthenticator.java create mode 100644 plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbAuthType.java create mode 100644 plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbAuthenticator.java create mode 100644 plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbAuthenticators.java create mode 100644 plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbConnectionTester.java create mode 100644 plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbDialect.java create mode 100644 plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbErrors.java create mode 100644 plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbFileName.java create mode 100644 plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbFileNameParser.java create mode 100644 plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbFileObject.java create mode 100644 plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbFileProvider.java create mode 100644 plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbFileSystem.java create mode 100644 plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbIdentity.java create mode 100644 plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbPaths.java create mode 100644 plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbRandomAccessContent.java create mode 100644 plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbSettings.java create mode 100644 plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbShare.java create mode 100644 plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbVfsPlugin.java create mode 100644 plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbjSmbShare.java create mode 100644 plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/metadata/SmbConnection.java create mode 100644 plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/metadata/SmbConnectionEditor.java create mode 100644 plugins/tech/smb/src/main/resources/org/apache/hop/vfs/smb/messages/messages_en_US.properties create mode 100644 plugins/tech/smb/src/main/resources/org/apache/hop/vfs/smb/metadata/messages/messages_en_US.properties create mode 100644 plugins/tech/smb/src/main/resources/smb.svg create mode 100644 plugins/tech/smb/src/main/resources/version.xml create mode 100644 plugins/tech/smb/src/test/docker/Dockerfile create mode 100644 plugins/tech/smb/src/test/docker/entrypoint.sh create mode 100644 plugins/tech/smb/src/test/docker/smb.conf create mode 100644 plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/MemorySmbShare.java create mode 100644 plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbConnectionSerializerTest.java create mode 100644 plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbFileObjectTest.java create mode 100644 plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbSupportTest.java create mode 100644 plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbVfsContainerIT.java diff --git a/assemblies/plugins/pom.xml b/assemblies/plugins/pom.xml index fa18ad39b4d..12cecb277a4 100644 --- a/assemblies/plugins/pom.xml +++ b/assemblies/plugins/pom.xml @@ -756,6 +756,12 @@ ${project.version} zip + + org.apache.hop + hop-tech-smb + ${project.version} + zip + org.apache.hop hop-tech-vault diff --git a/core/src/main/java/org/apache/hop/metadata/api/HopMetadataPropertyType.java b/core/src/main/java/org/apache/hop/metadata/api/HopMetadataPropertyType.java index 6c7a8e002e2..7331ae62a7c 100644 --- a/core/src/main/java/org/apache/hop/metadata/api/HopMetadataPropertyType.java +++ b/core/src/main/java/org/apache/hop/metadata/api/HopMetadataPropertyType.java @@ -123,6 +123,7 @@ public enum HopMetadataPropertyType { VFS_SFTP_CONNECTION, VFS_FTP_CONNECTION, VFS_HDFS_CONNECTION, + VFS_SMB_CONNECTION, // DATA STREAM DATA_STREAM, diff --git a/docs/hop-dev-manual/modules/ROOT/pages/plugin-types/data-connectivity.adoc b/docs/hop-dev-manual/modules/ROOT/pages/plugin-types/data-connectivity.adoc index b1c09b25577..588406b2ed7 100644 --- a/docs/hop-dev-manual/modules/ROOT/pages/plugin-types/data-connectivity.adoc +++ b/docs/hop-dev-manual/modules/ROOT/pages/plugin-types/data-connectivity.adoc @@ -95,6 +95,8 @@ The two stream classes are thin wrappers extending `CompressionInputStream` / `C Hop addresses every file through Apache VFS, so adding support for a new kind of storage means adding a VFS provider. This is how `s3://`, `azure://`, `gs://`, `ftp://` and `sftp://` work. HDFS is a named VFS connection (marketplace plugin `hop-tech-hadoop`); it does not register a global `hdfs://` scheme, because Native Spark already uses `hdfs://` as a Hadoop FileSystem URI. +SMB (`plugins/tech/smb`) is also a named connection only. +It uses smbj for SMB 2 and SMB 3, does not register `smb://`, and does not speak SMB1. Annotation:: `@VfsPlugin` Implement:: `IVfs` diff --git a/docs/hop-user-manual/modules/ROOT/nav.adoc b/docs/hop-user-manual/modules/ROOT/nav.adoc index 63672024f5f..861f17175ea 100644 --- a/docs/hop-user-manual/modules/ROOT/nav.adoc +++ b/docs/hop-user-manual/modules/ROOT/nav.adoc @@ -450,6 +450,7 @@ under the License. ** xref:vfs/google-drive-vfs.adoc[Google Drive] ** xref:vfs/hdfs-vfs.adoc[HDFS] ** xref:vfs/minio-vfs.adoc[MinIO] +** xref:vfs/smb-vfs.adoc[SMB] * xref:database/databases.adoc[Relational Database Connections] //::=START AUTO GENERATED LINKS DATABASES ** xref:database/databases/derby.adoc[Apache Derby] @@ -539,6 +540,7 @@ under the License. ** xref:metadata-types/rest-connection.adoc[REST Connection] ** xref:metadata-types/salesforce-connection.adoc[Salesforce Connection] ** xref:metadata-types/sftp-connection.adoc[SFTP Connection] +** xref:metadata-types/smb-connection.adoc[SMB Connection] ** xref:metadata-types/splunk-connection.adoc[Splunk Connection] ** xref:metadata-types/jms-connection.adoc[JMS connection] ** xref:metadata-types/spark-catalog.adoc[Spark Catalog] diff --git a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/smb-connection.adoc b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/smb-connection.adoc new file mode 100644 index 00000000000..6f455a81c56 --- /dev/null +++ b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/smb-connection.adoc @@ -0,0 +1,70 @@ +//// +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +//// +:imagesdir: ../../assets/images/ +:page-pagination: +:description: Named SMB2/SMB3 connection + += SMB Connection + +== Description + +This metadata type registers a Windows or Samba file share as a named VFS connection. +The connection name is the URL scheme, for example `finance:///reports/daily.csv`. + +The plugin talks SMB 2 and SMB 3 directly. +Nothing is mounted on the operating system, and SMB1 is not used. + +See xref:vfs/smb-vfs.adoc[SMB] for how paths are written. + +== Server + +[options="header"] +|=== +|Option|Description +|Hostname|Server name. For Azure Files this is `account.file.core.windows.net`. +|Port|TCP port. Default `445`. +|Share|Share name, without a leading slash. +|Folder in the share|Optional folder. Every path on this connection stays under that folder. +|=== + +== Authentication + +[options="header"] +|=== +|Option|Description +|NTLM|Username, password, and an optional NetBIOS domain. This is NTLMv2. A username of `DOMAIN\user` is split when Domain is empty. `user@dns.domain` is not parsed: put the domain in Domain and the sAMAccountName in Username. +|GUEST|Ignores username and password. Current Windows has guest SMB2 access off by default. +|=== + +Kerberos is not available in this version. +The connection type is the place it will be added. + +The password is stored with Hop's password encoder. +A variable such as `${SMB_PASSWORD}` is resolved at runtime. + +== Security + +[options="header"] +|=== +|Option|Description +|Minimum SMB version|Lowest dialect to offer. The client never goes above SMB 3.1.1 and never offers SMB1. +|Require SMB signing|Refuse a server that does not sign. When this is off, the client still signs if the server requires it. +|Encrypt session|Require SMB3 encryption. +|Follow DFS referrals|Follow Distributed File System referrals. Leave off unless the share is a DFS root. +|Call timeout|Read, write, and transact timeout in seconds. Default 60. +|Socket timeout|How long a socket read may block, in seconds. Default 60. +|=== diff --git a/docs/hop-user-manual/modules/ROOT/pages/vfs.adoc b/docs/hop-user-manual/modules/ROOT/pages/vfs.adoc index c4bd6a3cda9..53951eeddf8 100644 --- a/docs/hop-user-manual/modules/ROOT/pages/vfs.adoc +++ b/docs/hop-user-manual/modules/ROOT/pages/vfs.adoc @@ -51,6 +51,7 @@ Click the File system name to access more detailed file system documentation. |xref:vfs/google-cloud-storage-vfs.adoc[Google Cloud Storage]|Provides access to Google Cloud Storage buckets|`gs://` |xref:vfs/google-drive-vfs.adoc[Google Drive]|Provides access to Google Drive folders|`googledrive://` |xref:vfs/hdfs-vfs.adoc[HDFS]|Named connection to HDFS via HttpFS, Knox or WebHDFS (marketplace plugin `hop-tech-hadoop`)|`+:///path+` +|xref:vfs/smb-vfs.adoc[SMB]|Named connection to an SMB2/SMB3 file share|`+:///path+` |xref:metadata-types/minio-connection.adoc[Minio connection]|Provides access to S3-compatible endpoints via a named Minio connection (metadata)|`+://+` |xref:metadata-types/webdav-connection.adoc[WebDAV Connection]|Provides access to WebDAV servers, either via the static schemes or a named connection (metadata)|`webdav4://`, `webdav4s://`, or `+://+` |=== diff --git a/docs/hop-user-manual/modules/ROOT/pages/vfs/smb-vfs.adoc b/docs/hop-user-manual/modules/ROOT/pages/vfs/smb-vfs.adoc new file mode 100644 index 00000000000..4dbaed88383 --- /dev/null +++ b/docs/hop-user-manual/modules/ROOT/pages/vfs/smb-vfs.adoc @@ -0,0 +1,58 @@ +//// +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +//// + +:documentationPath: /vfs/ +:language: en_US +:description: SMB2 and SMB3 file shares through a named VFS connection + += SMB + +== Scheme + +SMB is a named VFS connection. +The scheme is the connection name: + +`finance:///reports/2026/daily.csv` + +Host, share, and credentials live on the xref:metadata-types/smb-connection.adoc[SMB Connection]. +They are not part of the URL. +A UNC path such as `\\server\share` is not a VFS URI, and Hop does not register `smb://`. + +Text File Input, Text File Output, Copy Files, and the VFS File Explorer use the connection directly. +There is no separate Get or Put action. + +== What it speaks + +The client is SMB 2.0.2 through SMB 3.1.1. +It does not speak SMB1. +That matches current Windows, where SMB1 is off. + +Sign-in for this version is NTLMv2 with a username and password, plus an optional domain. +Guest is available for a lab share that still allows it. +Current Windows has guest SMB2 access off by default. +Kerberos is not implemented yet. + +Azure Files is the same NTLM connection: hostname `account.file.core.windows.net`, username is the storage account, password is the access key, and the share is the file share name. + +== Check against Windows + +The automated tests run against Samba with SMB1 disabled, NTLMv2 only, and SMB signing required. +To confirm a real Windows share: + +. Create an xref:metadata-types/smb-connection.adoc[SMB Connection] aimed at a server that has SMB1 turned off. +. Use *Test connection*. +. Write a small file with Text File Output to `connectionName:///folder/file.csv` and read it back. diff --git a/plugins/tech/pom.xml b/plugins/tech/pom.xml index d0289ecbf30..bde7fd3e30b 100644 --- a/plugins/tech/pom.xml +++ b/plugins/tech/pom.xml @@ -52,6 +52,7 @@ redis salesforce sftp + smb vault webdav diff --git a/plugins/tech/smb/pom.xml b/plugins/tech/smb/pom.xml new file mode 100644 index 00000000000..3e29c0c68f6 --- /dev/null +++ b/plugins/tech/smb/pom.xml @@ -0,0 +1,104 @@ + + + + 4.0.0 + + + org.apache.hop + hop-plugins-tech + 2.20.0-SNAPSHOT + + + hop-tech-smb + jar + Hop Plugins Technology SMB + SMB2/SMB3 VFS plugin. Named connections only. No SMB1. + + + + org.bouncycastle:bcprov-jdk18on + 1.21.4 + + + + + com.hierynomus + smbj + 0.15.0 + + + org.bouncycastle + bcprov-jdk18on + + + org.slf4j + slf4j-api + + + + + org.bouncycastle + bcprov-jdk18on + 1.85.2 + provided + + + org.slf4j + slf4j-api + 2.0.18 + provided + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + org.testcontainers + junit-jupiter + ${testcontainers.version} + test + + + org.testcontainers + testcontainers + ${testcontainers.version} + test + + + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + + integration-test + verify + + + + + + + diff --git a/plugins/tech/smb/src/assembly/assembly.xml b/plugins/tech/smb/src/assembly/assembly.xml new file mode 100644 index 00000000000..b055b0aa620 --- /dev/null +++ b/plugins/tech/smb/src/assembly/assembly.xml @@ -0,0 +1,37 @@ + + + + hop-tech-smb + + zip + + . + + + ${project.basedir}/src/main/resources/version.xml + ${hop.plugin.libdir} + true + + + + + ${maven.multiModuleProjectDirectory}/assemblies/shared/hop-plugin-libs.xml + + diff --git a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/GuestSmbAuthenticator.java b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/GuestSmbAuthenticator.java new file mode 100644 index 00000000000..4819e0d2670 --- /dev/null +++ b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/GuestSmbAuthenticator.java @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.hop.vfs.smb; + +import com.hierynomus.smbj.auth.AuthenticationContext; +import org.apache.hop.core.variables.IVariables; +import org.apache.hop.vfs.smb.metadata.SmbConnection; + +/** Guest logon. Username and password on the connection are ignored. */ +final class GuestSmbAuthenticator implements SmbAuthenticator { + + @Override + public AuthenticationContext authenticate(SmbConnection connection, IVariables variables) { + return AuthenticationContext.guest(); + } +} diff --git a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/NtlmSmbAuthenticator.java b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/NtlmSmbAuthenticator.java new file mode 100644 index 00000000000..323a69961de --- /dev/null +++ b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/NtlmSmbAuthenticator.java @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.hop.vfs.smb; + +import com.hierynomus.smbj.auth.AuthenticationContext; +import org.apache.commons.lang3.StringUtils; +import org.apache.hop.core.Const; +import org.apache.hop.core.encryption.Encr; +import org.apache.hop.core.variables.IVariables; +import org.apache.hop.i18n.BaseMessages; +import org.apache.hop.vfs.smb.metadata.SmbConnection; + +/** NTLMv2 username and password. smbj negotiates NTLMv2; there is no NTLMv1 switch. */ +final class NtlmSmbAuthenticator implements SmbAuthenticator { + private static final Class PKG = NtlmSmbAuthenticator.class; + + @Override + public AuthenticationContext authenticate(SmbConnection connection, IVariables variables) { + SmbIdentity identity = + SmbIdentity.parse( + variables.resolve(connection.getDomain()), variables.resolve(connection.getUsername())); + if (StringUtils.isBlank(identity.username())) { + throw new IllegalArgumentException( + BaseMessages.getString(PKG, "Smb.Error.Username", Const.NVL(connection.getName(), ""))); + } + String password = + Encr.decryptPasswordOptionallyEncrypted( + variables.resolve(Const.NVL(connection.getPassword(), ""))); + return new AuthenticationContext( + identity.username(), password.toCharArray(), identity.domain()); + } +} diff --git a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbAuthType.java b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbAuthType.java new file mode 100644 index 00000000000..fb644e885f8 --- /dev/null +++ b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbAuthType.java @@ -0,0 +1,34 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.hop.vfs.smb; + +/** + * How this connection signs in. {@code toString()} stays {@link #name()} because the metadata combo + * stores that text. + * + *

Kerberos is the next value to add here: a new constant, a new {@link SmbAuthenticator}, and + * keytab fields on the same {@code smb-connection} metadata type. It is not implemented yet. + */ +public enum SmbAuthType { + /** NTLMv2 with a username, a password, and an optional domain. */ + NTLM, + /** + * Guest. Current Windows has guest SMB2 access off by default. Kept so a lab share that still + * allows it can be opened, and so a second authenticator exists behind the factory. + */ + GUEST +} diff --git a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbAuthenticator.java b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbAuthenticator.java new file mode 100644 index 00000000000..98b7bb8c5b1 --- /dev/null +++ b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbAuthenticator.java @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.hop.vfs.smb; + +import com.hierynomus.smbj.SmbConfig; +import com.hierynomus.smbj.auth.AuthenticationContext; +import org.apache.hop.core.variables.IVariables; +import org.apache.hop.vfs.smb.metadata.SmbConnection; + +/** + * Builds the smbj authentication context for one {@link SmbAuthType}. A future Kerberos + * implementation returns a {@code GSSAuthenticationContext} from {@link #authenticate} and, if it + * needs to, registers its authenticator from {@link #configure}. + */ +public interface SmbAuthenticator { + + /** + * Build the context. Implementations must not log the password or include it in an exception + * message. + */ + AuthenticationContext authenticate(SmbConnection connection, IVariables variables); + + /** Adjust the client config before connect. The default leaves the config alone. */ + default void configure(SmbConfig.Builder config) {} +} diff --git a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbAuthenticators.java b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbAuthenticators.java new file mode 100644 index 00000000000..5e9839f4267 --- /dev/null +++ b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbAuthenticators.java @@ -0,0 +1,32 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.hop.vfs.smb; + +/** Maps an auth type onto its authenticator. A null type is NTLM. */ +public final class SmbAuthenticators { + private static final SmbAuthenticator NTLM = new NtlmSmbAuthenticator(); + private static final SmbAuthenticator GUEST = new GuestSmbAuthenticator(); + + private SmbAuthenticators() {} + + public static SmbAuthenticator forType(SmbAuthType type) { + if (type == SmbAuthType.GUEST) { + return GUEST; + } + return NTLM; + } +} diff --git a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbConnectionTester.java b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbConnectionTester.java new file mode 100644 index 00000000000..bf5804f4538 --- /dev/null +++ b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbConnectionTester.java @@ -0,0 +1,50 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.hop.vfs.smb; + +import com.hierynomus.smbj.auth.AuthenticationContext; +import org.apache.hop.core.variables.IVariables; +import org.apache.hop.i18n.BaseMessages; +import org.apache.hop.vfs.smb.metadata.SmbConnection; + +/** Opens the share root. Used by the Test button on the connection editor. */ +public final class SmbConnectionTester { + private static final Class PKG = SmbConnectionTester.class; + + private SmbConnectionTester() {} + + public static String test(IVariables variables, SmbConnection connection) throws Exception { + SmbSettings settings = SmbSettings.resolve(connection, variables); + AuthenticationContext context = + SmbAuthenticators.forType(settings.authType()).authenticate(connection, variables); + String domain = context.getDomain() == null ? "" : context.getDomain(); + String username = context.getUsername() == null ? "" : context.getUsername(); + try (SmbShare share = + new SmbjSmbShare( + settings.toClientConfig(), + settings.host(), + settings.port(), + settings.share(), + domain, + username, + context)) { + share.children(""); + } + return BaseMessages.getString( + PKG, "Smb.Test.Ok", settings.host(), Integer.toString(settings.port()), settings.share()); + } +} diff --git a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbDialect.java b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbDialect.java new file mode 100644 index 00000000000..ae9e2fdcc2e --- /dev/null +++ b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbDialect.java @@ -0,0 +1,45 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.hop.vfs.smb; + +import com.hierynomus.mssmb2.SMB2Dialect; +import java.util.List; + +/** + * Lowest SMB dialect this connection will offer. The ceiling is always SMB 3.1.1. SMB1 is never in + * the list: smbj does not speak it. + */ +public enum SmbDialect { + SMB_2_0_2, + SMB_3_0, + SMB_3_1_1; + + /** Dialects from this floor through SMB 3.1.1, lowest first. */ + public List negotiated() { + return switch (this) { + case SMB_3_1_1 -> List.of(SMB2Dialect.SMB_3_1_1); + case SMB_3_0 -> List.of(SMB2Dialect.SMB_3_0, SMB2Dialect.SMB_3_0_2, SMB2Dialect.SMB_3_1_1); + case SMB_2_0_2 -> + List.of( + SMB2Dialect.SMB_2_0_2, + SMB2Dialect.SMB_2_1, + SMB2Dialect.SMB_3_0, + SMB2Dialect.SMB_3_0_2, + SMB2Dialect.SMB_3_1_1); + }; + } +} diff --git a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbErrors.java b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbErrors.java new file mode 100644 index 00000000000..49f82eeb111 --- /dev/null +++ b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbErrors.java @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.hop.vfs.smb; + +import com.hierynomus.mserref.NtStatus; +import com.hierynomus.mssmb2.SMBApiException; +import java.io.IOException; + +/** Turns smbj status codes into the two outcomes VFS cares about: missing, or a real failure. */ +final class SmbErrors { + private SmbErrors() {} + + static boolean notFound(Throwable error) { + Throwable current = error; + while (current != null) { + if (current instanceof SmbNotFoundException) { + return true; + } + if (current instanceof SMBApiException api) { + NtStatus status = api.getStatus(); + return status == NtStatus.STATUS_OBJECT_NAME_NOT_FOUND + || status == NtStatus.STATUS_OBJECT_PATH_NOT_FOUND + || status == NtStatus.STATUS_NO_SUCH_FILE + || status == NtStatus.STATUS_NOT_FOUND; + } + current = current.getCause(); + } + return false; + } + + static IOException io(Throwable error) { + if (error instanceof IOException ioException) { + return ioException; + } + String message = error.getMessage(); + if (message == null || message.isBlank()) { + message = error.getClass().getSimpleName(); + } + return new IOException(message, error); + } + + /** The path is not on the share. Distinct from access denied. */ + static final class SmbNotFoundException extends IOException { + SmbNotFoundException(String sharePath) { + super(sharePath == null || sharePath.isEmpty() ? "SMB path not found" : sharePath); + } + } +} diff --git a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbFileName.java b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbFileName.java new file mode 100644 index 00000000000..de0559be777 --- /dev/null +++ b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbFileName.java @@ -0,0 +1,40 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.hop.vfs.smb; + +import org.apache.commons.vfs2.FileName; +import org.apache.commons.vfs2.FileType; +import org.apache.commons.vfs2.provider.AbstractFileName; + +/** Named SMB connection URI: {@code connectionName:///path/under/the/share}. */ +public class SmbFileName extends AbstractFileName { + + public SmbFileName(String scheme, String path, FileType type) { + super(scheme, path, type); + } + + @Override + public FileName createName(String absPath, FileType type) { + return new SmbFileName(getScheme(), absPath, type); + } + + @Override + protected void appendRootUri(StringBuilder buffer, boolean addPassword) { + buffer.append(getScheme()); + buffer.append("://"); + } +} diff --git a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbFileNameParser.java b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbFileNameParser.java new file mode 100644 index 00000000000..d9ac72c29de --- /dev/null +++ b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbFileNameParser.java @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.hop.vfs.smb; + +import org.apache.commons.vfs2.FileName; +import org.apache.commons.vfs2.FileSystemException; +import org.apache.commons.vfs2.FileType; +import org.apache.commons.vfs2.provider.AbstractFileNameParser; +import org.apache.commons.vfs2.provider.FileNameParser; +import org.apache.commons.vfs2.provider.UriParser; +import org.apache.commons.vfs2.provider.VfsComponentContext; + +/** + * Parses {@code scheme:///path}. The scheme is the named connection. Credentials are not in the + * URI. + */ +public class SmbFileNameParser extends AbstractFileNameParser { + private static final SmbFileNameParser INSTANCE = new SmbFileNameParser(); + + public static FileNameParser getInstance() { + return INSTANCE; + } + + @Override + public FileName parseUri(VfsComponentContext context, FileName base, String uri) + throws FileSystemException { + StringBuilder name = new StringBuilder(); + String scheme = UriParser.extractScheme(uri, name); + UriParser.canonicalizePath(name, 0, name.length(), this); + UriParser.fixSeparators(name); + while (name.length() > 0 && name.charAt(0) == '/') { + name.deleteCharAt(0); + } + FileType fileType = UriParser.normalisePath(name); + String path = name.toString(); + if (path.isEmpty() || "/".equals(path)) { + path = "/"; + fileType = FileType.FOLDER; + } else if (!path.startsWith("/")) { + path = "/" + path; + } + return new SmbFileName(scheme, path, fileType); + } +} diff --git a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbFileObject.java b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbFileObject.java new file mode 100644 index 00000000000..714699b6a1b --- /dev/null +++ b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbFileObject.java @@ -0,0 +1,136 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.hop.vfs.smb; + +import java.io.InputStream; +import java.io.OutputStream; +import java.util.List; +import org.apache.commons.vfs2.FileObject; +import org.apache.commons.vfs2.FileSystemException; +import org.apache.commons.vfs2.FileType; +import org.apache.commons.vfs2.RandomAccessContent; +import org.apache.commons.vfs2.provider.AbstractFileName; +import org.apache.commons.vfs2.provider.AbstractFileObject; +import org.apache.commons.vfs2.util.RandomAccessMode; + +public class SmbFileObject extends AbstractFileObject { + private SmbShare.SmbEntry entry; + + protected SmbFileObject(AbstractFileName name, SmbFileSystem fileSystem) { + super(name, fileSystem); + } + + private SmbFileSystem fs() { + return (SmbFileSystem) getFileSystem(); + } + + private String sharePath() throws Exception { + return fs().toSharePath(getName()); + } + + @Override + protected void doAttach() throws Exception { + try { + entry = fs().share().stat(sharePath()); + } catch (Exception e) { + if (SmbErrors.notFound(e)) { + entry = null; + } else { + throw e; + } + } + injectType( + entry == null ? FileType.IMAGINARY : entry.directory() ? FileType.FOLDER : FileType.FILE); + } + + @Override + protected void doDetach() { + entry = null; + } + + @Override + protected FileType doGetType() { + if (entry == null) { + return FileType.IMAGINARY; + } + return entry.directory() ? FileType.FOLDER : FileType.FILE; + } + + @Override + protected long doGetContentSize() { + return entry == null ? 0 : entry.size(); + } + + @Override + protected long doGetLastModifiedTime() { + return entry == null ? 0 : entry.lastModified(); + } + + @Override + protected String[] doListChildren() throws Exception { + List names = fs().share().children(sharePath()); + return names.toArray(new String[0]); + } + + @Override + protected InputStream doGetInputStream() throws Exception { + return fs().share().openRead(sharePath()); + } + + /** + * HopVfs.getOutputStream() calls createFile() and then getOutputStream(). The default createFile + * opens and closes an output stream, which would create an empty file and then open a second one. + * Mark the type only. The bytes go out on the stream. + */ + @Override + public void createFile() throws FileSystemException { + if (!exists()) { + injectType(FileType.FILE); + } + } + + @Override + protected OutputStream doGetOutputStream(boolean append) throws Exception { + return fs().share().openWrite(sharePath(), append); + } + + @Override + protected RandomAccessContent doGetRandomAccessContent(RandomAccessMode mode) throws Exception { + return new SmbRandomAccessContent( + fs().share().openRandom(sharePath(), mode.requestWrite()), mode); + } + + @Override + protected void doCreateFolder() throws Exception { + fs().share().createFolder(sharePath()); + doDetach(); + doAttach(); + } + + @Override + protected void doDelete() throws Exception { + fs().share().delete(sharePath()); + doDetach(); + } + + @Override + protected void doRename(FileObject newFile) throws Exception { + String destination = fs().toSharePath(newFile.getName()); + fs().share().rename(sharePath(), destination); + doDetach(); + } +} diff --git a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbFileProvider.java b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbFileProvider.java new file mode 100644 index 00000000000..94920c46e18 --- /dev/null +++ b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbFileProvider.java @@ -0,0 +1,88 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.hop.vfs.smb; + +import com.hierynomus.smbj.auth.AuthenticationContext; +import java.util.Collection; +import org.apache.commons.vfs2.Capability; +import org.apache.commons.vfs2.FileName; +import org.apache.commons.vfs2.FileSystem; +import org.apache.commons.vfs2.FileSystemException; +import org.apache.commons.vfs2.FileSystemOptions; +import org.apache.commons.vfs2.provider.AbstractOriginatingFileProvider; +import org.apache.hop.core.variables.IVariables; +import org.apache.hop.vfs.smb.metadata.SmbConnection; + +public class SmbFileProvider extends AbstractOriginatingFileProvider { + private final IVariables variables; + private final SmbConnection connection; + private final SmbShare injected; + private final String injectedBasePath; + + public SmbFileProvider(IVariables variables, SmbConnection connection) { + this.variables = variables; + this.connection = connection; + this.injected = null; + this.injectedBasePath = null; + setFileNameParser(SmbFileNameParser.getInstance()); + } + + /** Test constructor. The share is already open and is not built from metadata. */ + public SmbFileProvider(SmbShare share, String basePath) { + this.variables = null; + this.connection = null; + this.injected = share; + this.injectedBasePath = basePath == null ? "" : basePath; + setFileNameParser(SmbFileNameParser.getInstance()); + } + + @Override + public Collection getCapabilities() { + return SmbFileSystem.CAPABILITIES; + } + + @Override + protected FileSystem doCreateFileSystem(FileName name, FileSystemOptions fileSystemOptions) + throws FileSystemException { + if (injected != null) { + return new SmbFileSystem(name, fileSystemOptions, injected, injectedBasePath); + } + try { + SmbSettings settings = SmbSettings.resolve(connection, variables); + AuthenticationContext context = + SmbAuthenticators.forType(settings.authType()).authenticate(connection, variables); + String domain = ""; + String username = ""; + if (!context.isGuest() && !context.isAnonymous()) { + domain = context.getDomain() == null ? "" : context.getDomain(); + username = context.getUsername() == null ? "" : context.getUsername(); + } + SmbShare share = + new SmbjSmbShare( + settings.toClientConfig(), + settings.host(), + settings.port(), + settings.share(), + domain, + username, + context); + return new SmbFileSystem(name, fileSystemOptions, share, settings.basePath()); + } catch (RuntimeException e) { + throw new FileSystemException(e.getMessage(), e); + } + } +} diff --git a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbFileSystem.java b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbFileSystem.java new file mode 100644 index 00000000000..70800251467 --- /dev/null +++ b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbFileSystem.java @@ -0,0 +1,87 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.hop.vfs.smb; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import org.apache.commons.vfs2.Capability; +import org.apache.commons.vfs2.FileName; +import org.apache.commons.vfs2.FileObject; +import org.apache.commons.vfs2.FileSystemOptions; +import org.apache.commons.vfs2.provider.AbstractFileName; +import org.apache.commons.vfs2.provider.AbstractFileSystem; + +/** + * One named connection. The SMB session lives on {@link #share} and is closed with the file system. + */ +public class SmbFileSystem extends AbstractFileSystem { + + public static final List CAPABILITIES = + Arrays.asList( + Capability.CREATE, + Capability.DELETE, + Capability.RENAME, + Capability.GET_TYPE, + Capability.LIST_CHILDREN, + Capability.READ_CONTENT, + Capability.WRITE_CONTENT, + Capability.APPEND_CONTENT, + Capability.URI, + Capability.GET_LAST_MODIFIED, + Capability.RANDOM_ACCESS_READ, + Capability.RANDOM_ACCESS_WRITE, + Capability.RANDOM_ACCESS_SET_LENGTH); + + private final SmbShare share; + private final String basePath; + + public SmbFileSystem( + FileName rootName, FileSystemOptions fileSystemOptions, SmbShare share, String basePath) { + super(rootName, null, fileSystemOptions); + this.share = share; + this.basePath = basePath == null ? "" : basePath; + } + + SmbShare share() { + return share; + } + + String toSharePath(FileName name) throws IOException { + return SmbPaths.sharePath(basePath, name.getPath()); + } + + @Override + protected void addCapabilities(Collection caps) { + caps.addAll(CAPABILITIES); + } + + @Override + protected FileObject createFile(AbstractFileName name) { + return new SmbFileObject(name, this); + } + + @Override + protected void doCloseCommunicationLink() { + try { + share.close(); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbIdentity.java b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbIdentity.java new file mode 100644 index 00000000000..0a7ca00bff3 --- /dev/null +++ b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbIdentity.java @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.hop.vfs.smb; + +/** + * Domain and username passed to NTLM. A filled domain field wins. Otherwise {@code DOMAIN\\user} in + * the username is split. {@code user@dns.domain} is left as the username: smbj wants the NetBIOS + * domain in its own field. + */ +public record SmbIdentity(String domain, String username) { + + public static SmbIdentity parse(String domainField, String usernameField) { + String domain = trim(domainField); + String username = trim(usernameField); + int slash = username.indexOf('\\'); + if (slash >= 0) { + if (domain.isEmpty()) { + domain = username.substring(0, slash).trim(); + } + username = username.substring(slash + 1).trim(); + } + return new SmbIdentity(domain, username); + } + + private static String trim(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbPaths.java b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbPaths.java new file mode 100644 index 00000000000..05adcdec641 --- /dev/null +++ b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbPaths.java @@ -0,0 +1,79 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.hop.vfs.smb; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import org.apache.hop.i18n.BaseMessages; + +/** Turns a VFS path into a share-relative SMB path and keeps it under the connection's folder. */ +public final class SmbPaths { + private static final Class PKG = SmbPaths.class; + + private SmbPaths() {} + + /** + * Share-relative path using {@code \} separators and no leading separator. Empty means the share + * root (or the base folder, when one is set). + */ + public static String sharePath(String basePath, String vfsPath) throws IOException { + String base = normalize(basePath); + String relative = normalize(vfsPath); + if (base.isEmpty()) { + return relative; + } + if (relative.isEmpty()) { + return base; + } + return base + "\\" + relative; + } + + static String normalize(String path) throws IOException { + if (path == null || path.isBlank()) { + return ""; + } + String unified = path.replace('\\', '/').trim(); + while (unified.startsWith("/")) { + unified = unified.substring(1); + } + while (unified.endsWith("/") && !unified.isEmpty()) { + unified = unified.substring(0, unified.length() - 1); + } + if (unified.isEmpty()) { + return ""; + } + List parts = new ArrayList<>(); + for (String part : unified.split("/")) { + if (part.isEmpty() || ".".equals(part)) { + continue; + } + if ("..".equals(part)) { + if (parts.isEmpty()) { + throw new IOException(BaseMessages.getString(PKG, "Smb.Error.PathEscapes", path)); + } + parts.remove(parts.size() - 1); + continue; + } + if (part.indexOf('\0') >= 0) { + throw new IOException(BaseMessages.getString(PKG, "Smb.Error.PathEscapes", path)); + } + parts.add(part); + } + return String.join("\\", parts); + } +} diff --git a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbRandomAccessContent.java b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbRandomAccessContent.java new file mode 100644 index 00000000000..9bf4b0fad75 --- /dev/null +++ b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbRandomAccessContent.java @@ -0,0 +1,140 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.hop.vfs.smb; + +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; +import java.io.IOException; +import java.io.InputStream; +import org.apache.commons.vfs2.FileSystemException; +import org.apache.commons.vfs2.provider.AbstractRandomAccessStreamContent; +import org.apache.commons.vfs2.util.RandomAccessMode; + +/** Random access over an SMB file. Reads and writes go to an absolute file offset. */ +final class SmbRandomAccessContent extends AbstractRandomAccessStreamContent { + private final SmbShare.SmbRandom random; + private final boolean write; + private long filePointer; + private DataInputStream dis; + + SmbRandomAccessContent(SmbShare.SmbRandom random, RandomAccessMode mode) { + super(mode); + this.random = random; + this.write = mode.requestWrite(); + } + + @Override + public void close() throws IOException { + if (dis != null) { + dis.close(); + dis = null; + } + random.close(); + } + + @Override + protected DataInputStream getDataInputStream() throws IOException { + if (dis == null) { + dis = new DataInputStream(new OffsetInputStream()); + } + return dis; + } + + @Override + public long getFilePointer() { + return filePointer; + } + + @Override + public void seek(long pos) throws IOException { + if (pos < 0) { + throw new FileSystemException( + "vfs.provider/random-access-invalid-position.error", Long.valueOf(pos)); + } + if (dis != null) { + dis.close(); + dis = null; + } + filePointer = pos; + } + + @Override + public long length() throws IOException { + return random.length(); + } + + @Override + public void setLength(long newLength) throws IOException { + random.setLength(newLength); + } + + @Override + public void write(int b) throws IOException { + write(new byte[] {(byte) b}, 0, 1); + } + + @Override + public void write(byte[] b) throws IOException { + write(b, 0, b.length); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + if (!write) { + throw new IOException("SMB file is open for read only"); + } + random.write(b, off, len, filePointer); + filePointer += len; + } + + @Override + public InputStream getInputStream() throws IOException { + // The inherited stream tracks the file pointer. A detached copy would not. + long size = Math.max(0, length() - filePointer); + if (size > Integer.MAX_VALUE) { + return super.getInputStream(); + } + byte[] data = new byte[(int) size]; + int offset = 0; + while (offset < data.length) { + int read = random.read(data, offset, data.length - offset, filePointer + offset); + if (read < 0) { + break; + } + offset += read; + } + return new ByteArrayInputStream(data, 0, offset); + } + + private final class OffsetInputStream extends InputStream { + @Override + public int read() throws IOException { + byte[] one = new byte[1]; + int n = read(one, 0, 1); + return n < 0 ? -1 : one[0] & 0xff; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + int n = random.read(b, off, len, filePointer); + if (n > 0) { + filePointer += n; + } + return n; + } + } +} diff --git a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbSettings.java b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbSettings.java new file mode 100644 index 00000000000..49c59504cb5 --- /dev/null +++ b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbSettings.java @@ -0,0 +1,122 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.hop.vfs.smb; + +import com.hierynomus.smbj.SmbConfig; +import java.util.concurrent.TimeUnit; +import org.apache.commons.lang3.StringUtils; +import org.apache.hop.core.Const; +import org.apache.hop.core.variables.IVariables; +import org.apache.hop.i18n.BaseMessages; +import org.apache.hop.vfs.smb.metadata.SmbConnection; + +/** + * Resolved connection settings. The password is not a field here, so logging this object cannot + * print it. + */ +public record SmbSettings( + String name, + String host, + int port, + String share, + String basePath, + SmbAuthType authType, + SmbDialect minimumDialect, + boolean requireSigning, + boolean encryptData, + boolean dfsEnabled, + int callTimeoutSeconds, + int socketTimeoutSeconds) { + + private static final Class PKG = SmbSettings.class; + static final int DEFAULT_PORT = 445; + static final int DEFAULT_TIMEOUT_SECONDS = 60; + + public static SmbSettings resolve(SmbConnection connection, IVariables variables) { + String name = Const.NVL(connection.getName(), ""); + String host = variables.resolve(Const.NVL(connection.getHostname(), "")).trim(); + if (StringUtils.isBlank(host)) { + throw new IllegalArgumentException(BaseMessages.getString(PKG, "Smb.Error.Hostname", name)); + } + String share = variables.resolve(Const.NVL(connection.getShare(), "")).trim(); + if (StringUtils.isBlank(share)) { + throw new IllegalArgumentException(BaseMessages.getString(PKG, "Smb.Error.Share", name)); + } + int port = positiveInt(variables.resolve(connection.getPort()), DEFAULT_PORT, name, "port"); + int callTimeout = + positiveInt( + variables.resolve(connection.getCallTimeoutSeconds()), + DEFAULT_TIMEOUT_SECONDS, + name, + "call timeout"); + int socketTimeout = + positiveInt( + variables.resolve(connection.getSocketTimeoutSeconds()), + DEFAULT_TIMEOUT_SECONDS, + name, + "socket timeout"); + SmbAuthType authType = + connection.getAuthType() == null ? SmbAuthType.NTLM : connection.getAuthType(); + SmbDialect dialect = + connection.getMinimumDialect() == null + ? SmbDialect.SMB_2_0_2 + : connection.getMinimumDialect(); + String basePath = variables.resolve(Const.NVL(connection.getBasePath(), "")).trim(); + return new SmbSettings( + name, + host, + port, + share, + basePath, + authType, + dialect, + connection.isRequireSigning(), + connection.isEncryptData(), + connection.isDfsEnabled(), + callTimeout, + socketTimeout); + } + + public SmbConfig toClientConfig() { + SmbConfig.Builder builder = + SmbConfig.builder() + .withDialects(minimumDialect.negotiated()) + .withSigningRequired(requireSigning) + .withEncryptData(encryptData) + .withDfsEnabled(dfsEnabled) + .withTimeout(callTimeoutSeconds, TimeUnit.SECONDS) + .withSoTimeout(socketTimeoutSeconds, TimeUnit.SECONDS); + SmbAuthenticators.forType(authType).configure(builder); + return builder.build(); + } + + private static int positiveInt(String raw, int fallback, String name, String field) { + if (raw == null || raw.isBlank()) { + return fallback; + } + try { + int value = Integer.parseInt(raw.trim()); + if (value <= 0) { + throw new NumberFormatException(field); + } + return value; + } catch (NumberFormatException e) { + throw new IllegalArgumentException( + BaseMessages.getString(PKG, "Smb.Error.PositiveNumber", name, field)); + } + } +} diff --git a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbShare.java b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbShare.java new file mode 100644 index 00000000000..33af74d8bc6 --- /dev/null +++ b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbShare.java @@ -0,0 +1,63 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.hop.vfs.smb; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.List; + +/** + * One SMB share session. Paths are share-relative and use {@code \} separators. An empty path is + * the root of the share (after the connection's base folder has already been applied). + * + *

{@link #stat} returns null when the path does not exist. Access denied and logon failure are + * thrown, so a permission error is not reported as a missing file. + */ +public interface SmbShare extends Closeable { + + SmbEntry stat(String sharePath) throws IOException; + + List children(String sharePath) throws IOException; + + InputStream openRead(String sharePath) throws IOException; + + OutputStream openWrite(String sharePath, boolean append) throws IOException; + + SmbRandom openRandom(String sharePath, boolean write) throws IOException; + + void createFolder(String sharePath) throws IOException; + + void delete(String sharePath) throws IOException; + + void rename(String sharePath, String newSharePath) throws IOException; + + /** A file or folder on the share. */ + record SmbEntry(boolean directory, long size, long lastModified) {} + + /** Offset read and write against one open file. Closing releases the server handle. */ + interface SmbRandom extends Closeable { + int read(byte[] buffer, int offset, int length, long fileOffset) throws IOException; + + void write(byte[] buffer, int offset, int length, long fileOffset) throws IOException; + + long length() throws IOException; + + void setLength(long length) throws IOException; + } +} diff --git a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbVfsPlugin.java b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbVfsPlugin.java new file mode 100644 index 00000000000..16351c8f704 --- /dev/null +++ b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbVfsPlugin.java @@ -0,0 +1,74 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.hop.vfs.smb; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.vfs2.provider.FileProvider; +import org.apache.hop.core.logging.LogChannel; +import org.apache.hop.core.variables.IVariables; +import org.apache.hop.core.vfs.plugin.IVfs; +import org.apache.hop.core.vfs.plugin.VfsPlugin; +import org.apache.hop.metadata.api.IHopMetadataProvider; +import org.apache.hop.metadata.util.HopMetadataUtil; +import org.apache.hop.vfs.smb.metadata.SmbConnection; + +@VfsPlugin(type = "smb", typeDescription = "SMB VFS", classLoaderGroup = "vfs-smb") +public class SmbVfsPlugin implements IVfs { + + @Override + public String[] getUrlSchemes() { + // Named connections only. Credentials stay on the metadata, never in an smb:// URI. + return new String[] {}; + } + + @Override + public FileProvider getProvider() { + return null; + } + + @Override + public Map getProviders(IVariables variables) { + return getProviders(variables, null); + } + + @Override + public Map getProviders( + IVariables variables, IHopMetadataProvider executionMetadata) { + Map providers = new HashMap<>(); + try { + IHopMetadataProvider metadataProvider = + executionMetadata != null + ? executionMetadata + : HopMetadataUtil.getStandardHopMetadataProvider(variables); + List connections = + metadataProvider.getSerializer(SmbConnection.class).loadAll(); + for (SmbConnection connection : connections) { + String name = connection.getName(); + if (StringUtils.isEmpty(name)) { + continue; + } + providers.put(name, new SmbFileProvider(variables, connection)); + } + } catch (Exception e) { + LogChannel.GENERAL.logError("Unable to load the SMB VFS providers", e); + } + return providers; + } +} diff --git a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbjSmbShare.java b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbjSmbShare.java new file mode 100644 index 00000000000..53c36c92855 --- /dev/null +++ b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbjSmbShare.java @@ -0,0 +1,422 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.hop.vfs.smb; + +import com.hierynomus.msdtyp.AccessMask; +import com.hierynomus.msfscc.FileAttributes; +import com.hierynomus.msfscc.fileinformation.FileAllInformation; +import com.hierynomus.msfscc.fileinformation.FileIdBothDirectoryInformation; +import com.hierynomus.mssmb2.SMB2CreateDisposition; +import com.hierynomus.mssmb2.SMB2CreateOptions; +import com.hierynomus.mssmb2.SMB2ShareAccess; +import com.hierynomus.mssmb2.SMBApiException; +import com.hierynomus.protocol.commons.EnumWithValue; +import com.hierynomus.smbj.SMBClient; +import com.hierynomus.smbj.SmbConfig; +import com.hierynomus.smbj.auth.AuthenticationContext; +import com.hierynomus.smbj.connection.Connection; +import com.hierynomus.smbj.session.Session; +import com.hierynomus.smbj.share.Directory; +import com.hierynomus.smbj.share.DiskShare; +import com.hierynomus.smbj.share.Share; +import java.io.FilterInputStream; +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; +import org.apache.hop.core.logging.HopLogStore; +import org.apache.hop.core.logging.ILogChannel; +import org.apache.hop.core.logging.LogChannel; + +/** + * One smbj client, connection, session, and disk share for the life of a VFS file system. File + * objects borrow the share. The TCP session is opened on the first call and only then. + */ +final class SmbjSmbShare implements SmbShare { + private static final Set READ_ACCESS = EnumSet.of(AccessMask.GENERIC_READ); + private static final Set WRITE_ACCESS = + EnumSet.of(AccessMask.GENERIC_READ, AccessMask.GENERIC_WRITE, AccessMask.DELETE); + private static final Set FILE_ATTRIBUTES = + EnumSet.of(FileAttributes.FILE_ATTRIBUTE_NORMAL); + private static final Set FILE_OPTIONS = + EnumSet.of(SMB2CreateOptions.FILE_NON_DIRECTORY_FILE); + + private final ILogChannel log; + private final SMBClient client; + private final String host; + private final int port; + private final String shareName; + private final String domain; + private final String username; + private final AuthenticationContext context; + + private Connection connection; + private Session session; + private DiskShare disk; + + SmbjSmbShare( + SmbConfig config, + String host, + int port, + String shareName, + String domain, + String username, + AuthenticationContext context) { + this.log = LogChannel.GENERAL; + this.client = new SMBClient(config); + this.host = host; + this.port = port; + this.shareName = shareName; + this.domain = domain == null ? "" : domain; + this.username = username == null ? "" : username; + this.context = context; + } + + @Override + public SmbEntry stat(String sharePath) throws IOException { + if (sharePath == null || sharePath.isEmpty()) { + return new SmbEntry(true, 0, 0); + } + try { + FileAllInformation info = disk().getFileInformation(sharePath); + long attributes = info.getBasicInformation().getFileAttributes(); + boolean directory = + EnumWithValue.EnumUtils.isSet(attributes, FileAttributes.FILE_ATTRIBUTE_DIRECTORY); + long size = directory ? 0 : info.getStandardInformation().getEndOfFile(); + long modified = info.getBasicInformation().getLastWriteTime().toEpochMillis(); + return new SmbEntry(directory, size, modified); + } catch (SMBApiException e) { + if (SmbErrors.notFound(e)) { + return null; + } + throw SmbErrors.io(e); + } + } + + @Override + public List children(String sharePath) throws IOException { + try { + List listed = disk().list(sharePath == null ? "" : sharePath); + List names = new ArrayList<>(); + for (FileIdBothDirectoryInformation child : listed) { + String name = childName(child.getFileName()); + if (!name.isEmpty()) { + names.add(name); + } + } + return names; + } catch (SMBApiException e) { + throw SmbErrors.io(e); + } + } + + @Override + public InputStream openRead(String sharePath) throws IOException { + com.hierynomus.smbj.share.File file = + disk() + .openFile( + sharePath, + READ_ACCESS, + FILE_ATTRIBUTES, + SMB2ShareAccess.ALL, + SMB2CreateDisposition.FILE_OPEN, + FILE_OPTIONS); + return new FilterInputStream(file.getInputStream()) { + @Override + public void close() throws IOException { + try { + super.close(); + } finally { + file.close(); + } + } + }; + } + + @Override + public OutputStream openWrite(String sharePath, boolean append) throws IOException { + mkdirs(parent(sharePath)); + com.hierynomus.smbj.share.File file = + disk() + .openFile( + sharePath, + WRITE_ACCESS, + FILE_ATTRIBUTES, + SMB2ShareAccess.ALL, + append + ? SMB2CreateDisposition.FILE_OPEN_IF + : SMB2CreateDisposition.FILE_OVERWRITE_IF, + FILE_OPTIONS); + // true starts the stream at the current end of the file. + return new FilterOutputStream(file.getOutputStream(append)) { + @Override + public void close() throws IOException { + try { + super.close(); + } finally { + file.close(); + } + } + }; + } + + @Override + public SmbRandom openRandom(String sharePath, boolean write) throws IOException { + com.hierynomus.smbj.share.File file = + disk() + .openFile( + sharePath, + write ? WRITE_ACCESS : READ_ACCESS, + FILE_ATTRIBUTES, + SMB2ShareAccess.ALL, + write ? SMB2CreateDisposition.FILE_OPEN_IF : SMB2CreateDisposition.FILE_OPEN, + FILE_OPTIONS); + return new SmbjRandom(file); + } + + @Override + public void createFolder(String sharePath) throws IOException { + mkdirs(sharePath); + } + + @Override + public void delete(String sharePath) throws IOException { + if (sharePath == null || sharePath.isEmpty()) { + throw new IOException("Cannot delete the root of an SMB share"); + } + SmbEntry entry = stat(sharePath); + if (entry == null) { + return; + } + try { + if (entry.directory()) { + disk().rmdir(sharePath, false); + } else { + disk().rm(sharePath); + } + } catch (SMBApiException e) { + throw SmbErrors.io(e); + } + } + + @Override + public void rename(String sharePath, String newSharePath) throws IOException { + SmbEntry entry = stat(sharePath); + if (entry == null) { + throw new SmbErrors.SmbNotFoundException(sharePath); + } + try { + if (entry.directory()) { + try (Directory directory = + disk() + .openDirectory( + sharePath, + WRITE_ACCESS, + EnumSet.of(FileAttributes.FILE_ATTRIBUTE_DIRECTORY), + SMB2ShareAccess.ALL, + SMB2CreateDisposition.FILE_OPEN, + EnumSet.of(SMB2CreateOptions.FILE_DIRECTORY_FILE))) { + directory.rename(newSharePath, true); + } + } else { + try (com.hierynomus.smbj.share.File file = + disk() + .openFile( + sharePath, + WRITE_ACCESS, + FILE_ATTRIBUTES, + SMB2ShareAccess.ALL, + SMB2CreateDisposition.FILE_OPEN, + FILE_OPTIONS)) { + file.rename(newSharePath, true); + } + } + } catch (SMBApiException e) { + throw SmbErrors.io(e); + } + } + + @Override + public void close() { + closeQuietly(disk); + closeQuietly(session); + closeQuietly(connection); + try { + client.close(); + } catch (Exception e) { + logError("Unable to close the SMB client for " + host, e); + } + disk = null; + session = null; + connection = null; + } + + private synchronized DiskShare disk() throws IOException { + if (disk != null && disk.isConnected()) { + return disk; + } + closeQuietly(disk); + closeQuietly(session); + closeQuietly(connection); + disk = null; + session = null; + connection = null; + Connection opened = null; + Session authenticated = null; + try { + logBasic( + "Opening SMB session " + + host + + ":" + + port + + " share " + + shareName + + " as " + + (domain.isEmpty() ? username : domain + "\\" + username)); + opened = client.connect(host, port); + authenticated = opened.authenticate(context); + Share connected = authenticated.connectShare(shareName); + if (!(connected instanceof DiskShare diskShare)) { + throw new IOException("SMB share \"" + shareName + "\" is not a disk share"); + } + connection = opened; + session = authenticated; + disk = diskShare; + return diskShare; + } catch (RuntimeException | IOException e) { + closeQuietly(authenticated); + closeQuietly(opened); + throw SmbErrors.io(e); + } + } + + private void mkdirs(String sharePath) throws IOException { + if (sharePath == null || sharePath.isEmpty() || stat(sharePath) != null) { + return; + } + mkdirs(parent(sharePath)); + try { + disk().mkdir(sharePath); + } catch (SMBApiException e) { + if (stat(sharePath) == null) { + throw SmbErrors.io(e); + } + } + } + + private static String parent(String sharePath) { + if (sharePath == null) { + return ""; + } + int slash = sharePath.lastIndexOf('\\'); + return slash < 0 ? "" : sharePath.substring(0, slash); + } + + private static String childName(String name) { + if (name == null || name.isEmpty() || ".".equals(name) || "..".equals(name)) { + return ""; + } + int slash = Math.max(name.lastIndexOf('\\'), name.lastIndexOf('/')); + if (slash >= 0) { + name = name.substring(slash + 1); + } + if (name.isEmpty() || ".".equals(name) || "..".equals(name)) { + return ""; + } + return name; + } + + private void logBasic(String message) { + if (HopLogStore.isInitialized()) { + log.logBasic(message); + } + } + + private void logError(String message, Exception error) { + if (HopLogStore.isInitialized()) { + log.logError(message, error); + } + } + + private static void closeQuietly(AutoCloseable closeable) { + if (closeable == null) { + return; + } + try { + closeable.close(); + } catch (Exception ignored) { + // The session is being dropped. The next call opens a new one. + } + } + + private static final class SmbjRandom implements SmbRandom { + private final com.hierynomus.smbj.share.File file; + + private SmbjRandom(com.hierynomus.smbj.share.File file) { + this.file = file; + } + + @Override + public int read(byte[] buffer, int offset, int length, long fileOffset) throws IOException { + if (length == 0) { + return 0; + } + try { + int read = file.read(buffer, fileOffset, offset, length); + return read == 0 ? -1 : read; + } catch (SMBApiException e) { + throw SmbErrors.io(e); + } + } + + @Override + public void write(byte[] buffer, int offset, int length, long fileOffset) throws IOException { + try { + file.write(buffer, fileOffset, offset, length); + } catch (SMBApiException e) { + throw SmbErrors.io(e); + } + } + + @Override + public long length() throws IOException { + try { + return file.getLength(); + } catch (SMBApiException e) { + throw SmbErrors.io(e); + } + } + + @Override + public void setLength(long length) throws IOException { + try { + file.setLength(length); + } catch (SMBApiException e) { + throw SmbErrors.io(e); + } + } + + @Override + public void close() { + file.close(); + } + } +} diff --git a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/metadata/SmbConnection.java b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/metadata/SmbConnection.java new file mode 100644 index 00000000000..e729cf18004 --- /dev/null +++ b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/metadata/SmbConnection.java @@ -0,0 +1,357 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.hop.vfs.smb.metadata; + +import java.io.Serializable; +import java.util.HashSet; +import java.util.Set; +import lombok.Getter; +import lombok.Setter; +import org.apache.hop.core.gui.plugin.GuiElementType; +import org.apache.hop.core.gui.plugin.GuiPlugin; +import org.apache.hop.core.gui.plugin.GuiWidgetElement; +import org.apache.hop.core.gui.plugin.GuiWidgetGroupType; +import org.apache.hop.core.vfs.IVfsBrowseLocation; +import org.apache.hop.i18n.BaseMessages; +import org.apache.hop.metadata.api.HopMetadata; +import org.apache.hop.metadata.api.HopMetadataBase; +import org.apache.hop.metadata.api.HopMetadataCategory; +import org.apache.hop.metadata.api.HopMetadataProperty; +import org.apache.hop.metadata.api.HopMetadataPropertyType; +import org.apache.hop.metadata.api.IHopMetadata; +import org.apache.hop.ui.core.dialog.ErrorDialog; +import org.apache.hop.ui.core.gui.GuiCompositeWidgets; +import org.apache.hop.ui.core.gui.IGuiPluginCompositeWidgetsListener; +import org.apache.hop.ui.core.widget.ComboVar; +import org.apache.hop.ui.hopgui.HopGui; +import org.apache.hop.vfs.smb.SmbAuthType; +import org.apache.hop.vfs.smb.SmbConnectionTester; +import org.apache.hop.vfs.smb.SmbDialect; +import org.eclipse.swt.SWT; +import org.eclipse.swt.widgets.Combo; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.MessageBox; + +@Getter +@Setter +@GuiPlugin(classLoaderGroup = "vfs-smb") +@HopMetadata( + key = "smb-connection", + name = "i18n::SmbConnection.Name", + description = "i18n::SmbConnection.Description", + image = "smb.svg", + category = HopMetadataCategory.FILE_STORAGE, + documentationUrl = "/metadata-types/smb-connection.html", + hopMetadataPropertyType = HopMetadataPropertyType.VFS_SMB_CONNECTION, + classLoaderGroup = "vfs-smb") +public class SmbConnection extends HopMetadataBase + implements Serializable, IHopMetadata, IVfsBrowseLocation, IGuiPluginCompositeWidgetsListener { + + private static final Class PKG = SmbConnection.class; + + public static final String GROUP_SERVER = "Server"; + public static final String GROUP_AUTHENTICATION = "Authentication"; + public static final String GROUP_SECURITY = "Security"; + + public static final String WIDGET_AUTH = "smb-auth-type"; + public static final String WIDGET_DOMAIN = "smb-auth-domain"; + public static final String WIDGET_USERNAME = "smb-auth-username"; + public static final String WIDGET_PASSWORD = "smb-auth-password"; + + @GuiWidgetElement( + id = "smb-description", + order = "0100", + parentId = SmbConnectionEditor.GUI_PLUGIN_ELEMENT_PARENT_ID, + type = GuiElementType.TEXT, + label = "i18n::Smb.Description.Label", + toolTip = "i18n::Smb.Description.Tooltip", + groupType = GuiWidgetGroupType.TABS, + group = GROUP_SERVER, + groupOrder = "010") + @HopMetadataProperty + private String description; + + @GuiWidgetElement( + id = "smb-hostname", + order = "0200", + parentId = SmbConnectionEditor.GUI_PLUGIN_ELEMENT_PARENT_ID, + type = GuiElementType.TEXT, + label = "i18n::Smb.Hostname.Label", + toolTip = "i18n::Smb.Hostname.Tooltip", + groupType = GuiWidgetGroupType.TABS, + group = GROUP_SERVER, + groupOrder = "010") + @HopMetadataProperty + private String hostname; + + @GuiWidgetElement( + id = "smb-port", + order = "0300", + parentId = SmbConnectionEditor.GUI_PLUGIN_ELEMENT_PARENT_ID, + type = GuiElementType.TEXT, + label = "i18n::Smb.Port.Label", + toolTip = "i18n::Smb.Port.Tooltip", + groupType = GuiWidgetGroupType.TABS, + group = GROUP_SERVER, + groupOrder = "010") + @HopMetadataProperty + private String port = "445"; + + @GuiWidgetElement( + id = "smb-share", + order = "0400", + parentId = SmbConnectionEditor.GUI_PLUGIN_ELEMENT_PARENT_ID, + type = GuiElementType.TEXT, + label = "i18n::Smb.Share.Label", + toolTip = "i18n::Smb.Share.Tooltip", + groupType = GuiWidgetGroupType.TABS, + group = GROUP_SERVER, + groupOrder = "010") + @HopMetadataProperty + private String share; + + @GuiWidgetElement( + id = "smb-base-path", + order = "0500", + parentId = SmbConnectionEditor.GUI_PLUGIN_ELEMENT_PARENT_ID, + type = GuiElementType.TEXT, + label = "i18n::Smb.BasePath.Label", + toolTip = "i18n::Smb.BasePath.Tooltip", + groupType = GuiWidgetGroupType.TABS, + group = GROUP_SERVER, + groupOrder = "010") + @HopMetadataProperty + private String basePath; + + @GuiWidgetElement( + id = "smb-test", + order = "0600", + parentId = SmbConnectionEditor.GUI_PLUGIN_ELEMENT_PARENT_ID, + type = GuiElementType.BUTTON, + label = "i18n::Smb.Test.Label", + toolTip = "i18n::Smb.Test.Tooltip", + groupType = GuiWidgetGroupType.TABS, + group = GROUP_SERVER, + groupOrder = "010") + public void testConnection(Object object) { + SmbConnection meta = (SmbConnection) object; + HopGui hopGui = HopGui.getInstance(); + String title = BaseMessages.getString(PKG, "Smb.Test.Title"); + try { + String result = SmbConnectionTester.test(hopGui.getVariables(), meta); + MessageBox box = new MessageBox(hopGui.getShell(), SWT.OK | SWT.ICON_INFORMATION); + box.setText(title); + box.setMessage(result); + box.open(); + } catch (Exception e) { + new ErrorDialog(hopGui.getShell(), title, BaseMessages.getString(PKG, "Smb.Test.Error"), e); + } + } + + @GuiWidgetElement( + id = WIDGET_AUTH, + order = "1000", + parentId = SmbConnectionEditor.GUI_PLUGIN_ELEMENT_PARENT_ID, + type = GuiElementType.COMBO, + variables = false, + label = "i18n::Smb.AuthType.Label", + toolTip = "i18n::Smb.AuthType.Tooltip", + groupType = GuiWidgetGroupType.TABS, + group = GROUP_AUTHENTICATION, + groupOrder = "020") + @HopMetadataProperty + private SmbAuthType authType = SmbAuthType.NTLM; + + @GuiWidgetElement( + id = WIDGET_DOMAIN, + order = "1100", + parentId = SmbConnectionEditor.GUI_PLUGIN_ELEMENT_PARENT_ID, + type = GuiElementType.TEXT, + label = "i18n::Smb.Domain.Label", + toolTip = "i18n::Smb.Domain.Tooltip", + groupType = GuiWidgetGroupType.TABS, + group = GROUP_AUTHENTICATION, + groupOrder = "020") + @HopMetadataProperty + private String domain; + + @GuiWidgetElement( + id = WIDGET_USERNAME, + order = "1200", + parentId = SmbConnectionEditor.GUI_PLUGIN_ELEMENT_PARENT_ID, + type = GuiElementType.TEXT, + label = "i18n::Smb.Username.Label", + toolTip = "i18n::Smb.Username.Tooltip", + groupType = GuiWidgetGroupType.TABS, + group = GROUP_AUTHENTICATION, + groupOrder = "020") + @HopMetadataProperty + private String username; + + @GuiWidgetElement( + id = WIDGET_PASSWORD, + order = "1300", + parentId = SmbConnectionEditor.GUI_PLUGIN_ELEMENT_PARENT_ID, + type = GuiElementType.TEXT, + password = true, + label = "i18n::Smb.Password.Label", + toolTip = "i18n::Smb.Password.Tooltip", + groupType = GuiWidgetGroupType.TABS, + group = GROUP_AUTHENTICATION, + groupOrder = "020") + @HopMetadataProperty(password = true) + private String password; + + @GuiWidgetElement( + id = "smb-dialect", + order = "2000", + parentId = SmbConnectionEditor.GUI_PLUGIN_ELEMENT_PARENT_ID, + type = GuiElementType.COMBO, + variables = false, + label = "i18n::Smb.Dialect.Label", + toolTip = "i18n::Smb.Dialect.Tooltip", + groupType = GuiWidgetGroupType.TABS, + group = GROUP_SECURITY, + groupOrder = "030") + @HopMetadataProperty + private SmbDialect minimumDialect = SmbDialect.SMB_2_0_2; + + @GuiWidgetElement( + id = "smb-require-signing", + order = "2100", + parentId = SmbConnectionEditor.GUI_PLUGIN_ELEMENT_PARENT_ID, + type = GuiElementType.CHECKBOX, + label = "i18n::Smb.RequireSigning.Label", + toolTip = "i18n::Smb.RequireSigning.Tooltip", + groupType = GuiWidgetGroupType.TABS, + group = GROUP_SECURITY, + groupOrder = "030") + @HopMetadataProperty + private boolean requireSigning; + + @GuiWidgetElement( + id = "smb-encrypt", + order = "2200", + parentId = SmbConnectionEditor.GUI_PLUGIN_ELEMENT_PARENT_ID, + type = GuiElementType.CHECKBOX, + label = "i18n::Smb.Encrypt.Label", + toolTip = "i18n::Smb.Encrypt.Tooltip", + groupType = GuiWidgetGroupType.TABS, + group = GROUP_SECURITY, + groupOrder = "030") + @HopMetadataProperty + private boolean encryptData; + + @GuiWidgetElement( + id = "smb-dfs", + order = "2300", + parentId = SmbConnectionEditor.GUI_PLUGIN_ELEMENT_PARENT_ID, + type = GuiElementType.CHECKBOX, + label = "i18n::Smb.Dfs.Label", + toolTip = "i18n::Smb.Dfs.Tooltip", + groupType = GuiWidgetGroupType.TABS, + group = GROUP_SECURITY, + groupOrder = "030") + @HopMetadataProperty + private boolean dfsEnabled; + + @GuiWidgetElement( + id = "smb-call-timeout", + order = "2400", + parentId = SmbConnectionEditor.GUI_PLUGIN_ELEMENT_PARENT_ID, + type = GuiElementType.TEXT, + label = "i18n::Smb.CallTimeout.Label", + toolTip = "i18n::Smb.CallTimeout.Tooltip", + groupType = GuiWidgetGroupType.TABS, + group = GROUP_SECURITY, + groupOrder = "030") + @HopMetadataProperty + private String callTimeoutSeconds = "60"; + + @GuiWidgetElement( + id = "smb-socket-timeout", + order = "2500", + parentId = SmbConnectionEditor.GUI_PLUGIN_ELEMENT_PARENT_ID, + type = GuiElementType.TEXT, + label = "i18n::Smb.SocketTimeout.Label", + toolTip = "i18n::Smb.SocketTimeout.Tooltip", + groupType = GuiWidgetGroupType.TABS, + group = GROUP_SECURITY, + groupOrder = "030") + @HopMetadataProperty + private String socketTimeoutSeconds = "60"; + + public SmbConnection() { + this.port = "445"; + this.authType = SmbAuthType.NTLM; + this.minimumDialect = SmbDialect.SMB_2_0_2; + this.callTimeoutSeconds = "60"; + this.socketTimeoutSeconds = "60"; + } + + @Override + public void widgetsCreated(GuiCompositeWidgets compositeWidgets) { + // Values are not on the widgets yet. + } + + @Override + public void widgetsPopulated(GuiCompositeWidgets compositeWidgets) { + hideCredentialsWhenGuest(compositeWidgets); + } + + @Override + public void widgetModified( + GuiCompositeWidgets compositeWidgets, Control changedWidget, String widgetId) { + hideCredentialsWhenGuest(compositeWidgets); + } + + @Override + public void persistContents(GuiCompositeWidgets compositeWidgets) { + // The editor reads the widgets back itself. + } + + private void hideCredentialsWhenGuest(GuiCompositeWidgets compositeWidgets) { + if (compositeWidgets.getWidgetsMap() == null) { + return; + } + Set hidden = new HashSet<>(); + if (selectedAuth(compositeWidgets) == SmbAuthType.GUEST) { + hidden.add(WIDGET_DOMAIN); + hidden.add(WIDGET_USERNAME); + hidden.add(WIDGET_PASSWORD); + } + compositeWidgets.setWidgetsHidden(this, hidden); + } + + private SmbAuthType selectedAuth(GuiCompositeWidgets compositeWidgets) { + Control control = compositeWidgets.getWidgetsMap().get(WIDGET_AUTH); + String text = null; + if (control instanceof Combo combo) { + text = combo.getText(); + } else if (control instanceof ComboVar comboVar) { + text = comboVar.getText(); + } + if (text != null && !text.isBlank()) { + try { + return SmbAuthType.valueOf(text.trim()); + } catch (IllegalArgumentException e) { + // Nothing selected yet. The field is the better answer. + } + } + return authType == null ? SmbAuthType.NTLM : authType; + } +} diff --git a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/metadata/SmbConnectionEditor.java b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/metadata/SmbConnectionEditor.java new file mode 100644 index 00000000000..607b6b5b318 --- /dev/null +++ b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/metadata/SmbConnectionEditor.java @@ -0,0 +1,175 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.hop.vfs.smb.metadata; + +import org.apache.hop.core.Const; +import org.apache.hop.core.exception.HopException; +import org.apache.hop.core.gui.plugin.GuiPlugin; +import org.apache.hop.core.vfs.HopVfs; +import org.apache.hop.i18n.BaseMessages; +import org.apache.hop.ui.core.PropsUi; +import org.apache.hop.ui.core.gui.GuiCompositeWidgets; +import org.apache.hop.ui.core.gui.GuiCompositeWidgetsAdapter; +import org.apache.hop.ui.core.gui.IGuiPluginCompositeButtonsListener; +import org.apache.hop.ui.core.metadata.MetadataEditor; +import org.apache.hop.ui.core.metadata.MetadataManager; +import org.apache.hop.ui.core.widget.NamingSchemeTypes; +import org.apache.hop.ui.core.widget.TextVar; +import org.apache.hop.ui.hopgui.HopGui; +import org.apache.hop.ui.hopgui.vfs.explorer.VfsFileExplorerViews; +import org.eclipse.swt.SWT; +import org.eclipse.swt.layout.FormAttachment; +import org.eclipse.swt.layout.FormData; +import org.eclipse.swt.layout.FormLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Label; + +@GuiPlugin(description = "Editor for SMB connection metadata", classLoaderGroup = "vfs-smb") +public class SmbConnectionEditor extends MetadataEditor { + + private static final Class PKG = SmbConnectionEditor.class; + + public static final String GUI_PLUGIN_ELEMENT_PARENT_ID = "SmbConnectionEditor-GuiWidgetsParent"; + + private TextVar wName; + private Composite wWidgetsComposite; + private GuiCompositeWidgets guiCompositeWidgets; + + public SmbConnectionEditor( + HopGui hopGui, MetadataManager manager, SmbConnection metadata) { + super(hopGui, manager, metadata); + } + + @Override + public void createControl(Composite parent) { + PropsUi props = PropsUi.getInstance(); + int middle = props.getMiddlePct(); + int margin = PropsUi.getMargin() + 2; + + Label wIcon = new Label(parent, SWT.RIGHT); + wIcon.setImage(getImage()); + FormData fdlIcon = new FormData(); + fdlIcon.top = new FormAttachment(0, 0); + fdlIcon.right = new FormAttachment(100, 0); + wIcon.setLayoutData(fdlIcon); + PropsUi.setLook(wIcon); + + Label wlName = new Label(parent, SWT.RIGHT); + PropsUi.setLook(wlName); + wlName.setText(BaseMessages.getString(PKG, "SmbConnection.Name.Label")); + FormData fdlName = new FormData(); + fdlName.top = new FormAttachment(0, margin); + fdlName.left = new FormAttachment(0, 0); + fdlName.right = new FormAttachment(middle, -margin); + wlName.setLayoutData(fdlName); + wName = + new TextVar(hopGui.getVariables(), parent, SWT.SINGLE | SWT.LEFT | SWT.BORDER) + .asNameField(NamingSchemeTypes.HOP_METADATA); + PropsUi.setLook(wName); + FormData fdName = new FormData(); + fdName.top = new FormAttachment(wlName, 0, SWT.CENTER); + fdName.left = new FormAttachment(middle, 0); + fdName.right = new FormAttachment(wIcon, -margin); + wName.setLayoutData(fdName); + Control lastControl = wName; + + wWidgetsComposite = new Composite(parent, SWT.NONE); + PropsUi.setLook(wWidgetsComposite); + wWidgetsComposite.setLayout(new FormLayout()); + FormData fdWidgetsComposite = new FormData(); + fdWidgetsComposite.top = new FormAttachment(lastControl, margin); + fdWidgetsComposite.left = new FormAttachment(0, 0); + fdWidgetsComposite.right = new FormAttachment(100, 0); + fdWidgetsComposite.bottom = new FormAttachment(100, 0); + wWidgetsComposite.setLayoutData(fdWidgetsComposite); + + guiCompositeWidgets = new GuiCompositeWidgets(manager.getVariables()); + guiCompositeWidgets.createCompositeWidgets( + metadata, null, wWidgetsComposite, GUI_PLUGIN_ELEMENT_PARENT_ID, lastControl); + guiCompositeWidgets.setWidgetsListener( + new GuiCompositeWidgetsAdapter() { + @Override + public void widgetsCreated(GuiCompositeWidgets compositeWidgets) { + metadata.widgetsCreated(compositeWidgets); + } + + @Override + public void widgetsPopulated(GuiCompositeWidgets compositeWidgets) { + metadata.widgetsPopulated(compositeWidgets); + } + + @Override + public void widgetModified( + GuiCompositeWidgets compositeWidgets, Control changedWidget, String widgetId) { + setChanged(); + metadata.widgetModified(compositeWidgets, changedWidget, widgetId); + } + + @Override + public void persistContents(GuiCompositeWidgets compositeWidgets) { + metadata.persistContents(compositeWidgets); + } + }); + guiCompositeWidgets.setCompositeButtonsListener( + new IGuiPluginCompositeButtonsListener() { + @Override + public void buttonPressed(Object sourceObject) { + Object model = sourceObject != null ? sourceObject : metadata; + getWidgetsContent((SmbConnection) model); + } + }); + + setWidgetsContent(); + resetChanged(); + wName.addModifyListener(e -> setChanged()); + } + + @Override + public void setWidgetsContent() { + SmbConnection meta = getMetadata(); + wName.setText(Const.NVL(meta.getName(), "")); + guiCompositeWidgets.setWidgetsContents( + metadata, wWidgetsComposite, GUI_PLUGIN_ELEMENT_PARENT_ID); + } + + @Override + public void getWidgetsContent(SmbConnection meta) { + meta.setName(wName.getText()); + guiCompositeWidgets.getWidgetsContents(metadata, GUI_PLUGIN_ELEMENT_PARENT_ID); + } + + @Override + public boolean setFocus() { + if (wName == null || wName.isDisposed()) { + return false; + } + return wName.setFocus(); + } + + @Override + public void save() throws HopException { + super.save(); + HopVfs.refresh(hopGui.getVariables()); + } + + @Override + public Button[] createButtonsForButtonBar(Composite parent) { + return VfsFileExplorerViews.exploreButton(parent, this); + } +} diff --git a/plugins/tech/smb/src/main/resources/org/apache/hop/vfs/smb/messages/messages_en_US.properties b/plugins/tech/smb/src/main/resources/org/apache/hop/vfs/smb/messages/messages_en_US.properties new file mode 100644 index 00000000000..58329518c6f --- /dev/null +++ b/plugins/tech/smb/src/main/resources/org/apache/hop/vfs/smb/messages/messages_en_US.properties @@ -0,0 +1,22 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +Smb.Error.Hostname = SMB connection "{0}" needs a hostname +Smb.Error.Share = SMB connection "{0}" needs a share name +Smb.Error.PositiveNumber = SMB connection "{0}" {1} must be a positive number +Smb.Error.Username = SMB connection "{0}" needs a username +Smb.Error.PathEscapes = The path leaves the folder this SMB connection is rooted at: {0} +Smb.Test.Ok = Connected to {0}:{1}, share "{2}" diff --git a/plugins/tech/smb/src/main/resources/org/apache/hop/vfs/smb/metadata/messages/messages_en_US.properties b/plugins/tech/smb/src/main/resources/org/apache/hop/vfs/smb/metadata/messages/messages_en_US.properties new file mode 100644 index 00000000000..c15f8c33f07 --- /dev/null +++ b/plugins/tech/smb/src/main/resources/org/apache/hop/vfs/smb/metadata/messages/messages_en_US.properties @@ -0,0 +1,53 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +SmbConnection.Name = SMB Connection +SmbConnection.Description = Read and write an SMB2 or SMB3 file share without mounting it +SmbConnection.Name.Label = Name +Smb.Description.Label = Description +Smb.Description.Tooltip = Optional description of this connection +Smb.Hostname.Label = Hostname +Smb.Hostname.Tooltip = Server hostname. Example: fileserver.example.com or '${SMB_HOST}'. Azure Files uses account.file.core.windows.net. +Smb.Port.Label = Port +Smb.Port.Tooltip = TCP port. Default 445. Example: '${SMB_PORT}' +Smb.Share.Label = Share +Smb.Share.Tooltip = Share name on the server, without a leading slash. Example: data or '${SMB_SHARE}' +Smb.BasePath.Label = Folder in the share +Smb.BasePath.Tooltip = Optional folder inside the share. Paths on this connection stay under that folder. Empty is the share root. Example: '${SMB_FOLDER}' +Smb.Test.Label = Test connection +Smb.Test.Tooltip = Sign in and list the share root +Smb.Test.Title = SMB connection +Smb.Test.Error = Could not open the SMB share +Smb.AuthType.Label = Authentication +Smb.AuthType.Tooltip = NTLM is a username and password (NTLMv2). GUEST ignores the username and password. Current Windows has guest access off by default. Kerberos is not available yet. +Smb.Domain.Label = Domain +Smb.Domain.Tooltip = NetBIOS domain for an Active Directory account. Leave empty for a local account. A username of DOMAIN\\user is split when this is empty. user@dns.domain is not parsed. Example: '${SMB_DOMAIN}' +Smb.Username.Label = Username +Smb.Username.Tooltip = Account name. For Active Directory use the sAMAccountName, or DOMAIN\\user. Example: '${SMB_USER}' +Smb.Password.Label = Password +Smb.Password.Tooltip = Password for the account. Example: '${SMB_PASSWORD}' +Smb.Dialect.Label = Minimum SMB version +Smb.Dialect.Tooltip = Lowest dialect to offer. SMB 2.0.2 reaches current Windows and Samba. SMB 3.0 or SMB 3.1.1 refuses older servers. SMB1 is never offered. +Smb.RequireSigning.Label = Require SMB signing +Smb.RequireSigning.Tooltip = Refuse a server that does not sign. Leave off to still sign when the server requires it, which is the usual Windows policy. +Smb.Encrypt.Label = Encrypt session +Smb.Encrypt.Tooltip = Require SMB3 encryption. Turn this on when the share rejects unencrypted sessions. +Smb.Dfs.Label = Follow DFS referrals +Smb.Dfs.Tooltip = Let the client follow Distributed File System referrals. Leave off unless the share is a DFS root. +Smb.CallTimeout.Label = Call timeout (seconds) +Smb.CallTimeout.Tooltip = Read, write, and transact timeout. Default 60. Example: '${SMB_TIMEOUT}' +Smb.SocketTimeout.Label = Socket timeout (seconds) +Smb.SocketTimeout.Tooltip = How long a socket read may block. Default 60. A value of 0 is not accepted. diff --git a/plugins/tech/smb/src/main/resources/smb.svg b/plugins/tech/smb/src/main/resources/smb.svg new file mode 100644 index 00000000000..3f6e1e4279f --- /dev/null +++ b/plugins/tech/smb/src/main/resources/smb.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/plugins/tech/smb/src/main/resources/version.xml b/plugins/tech/smb/src/main/resources/version.xml new file mode 100644 index 00000000000..36ab20e22eb --- /dev/null +++ b/plugins/tech/smb/src/main/resources/version.xml @@ -0,0 +1,20 @@ + + + +${project.version} diff --git a/plugins/tech/smb/src/test/docker/Dockerfile b/plugins/tech/smb/src/test/docker/Dockerfile new file mode 100644 index 00000000000..3bef9c30d7c --- /dev/null +++ b/plugins/tech/smb/src/test/docker/Dockerfile @@ -0,0 +1,27 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +FROM debian:bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends samba \ + && rm -rf /var/lib/apt/lists/* + +COPY smb.conf /etc/samba/smb.conf +COPY entrypoint.sh /entrypoint.sh +RUN chmod 0755 /entrypoint.sh + +EXPOSE 445 +ENTRYPOINT ["/entrypoint.sh"] diff --git a/plugins/tech/smb/src/test/docker/entrypoint.sh b/plugins/tech/smb/src/test/docker/entrypoint.sh new file mode 100644 index 00000000000..772b215c73f --- /dev/null +++ b/plugins/tech/smb/src/test/docker/entrypoint.sh @@ -0,0 +1,27 @@ +#!/bin/sh +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -eu + +if [ "${SMB_ENCRYPT:-}" = "required" ]; then + sed -i 's/smb encrypt = desired/smb encrypt = required/' /etc/samba/smb.conf +fi + +id hop >/dev/null 2>&1 || useradd -M -s /usr/sbin/nologin hop +printf '%s\n%s\n' "$SMB_PASSWORD" "$SMB_PASSWORD" | smbpasswd -a -s hop +mkdir -p /share +chown hop:hop /share +exec smbd -F --no-process-group --debug-stdout diff --git a/plugins/tech/smb/src/test/docker/smb.conf b/plugins/tech/smb/src/test/docker/smb.conf new file mode 100644 index 00000000000..1bc527ac499 --- /dev/null +++ b/plugins/tech/smb/src/test/docker/smb.conf @@ -0,0 +1,39 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +[global] + workgroup = WORKGROUP + server role = standalone server + security = user + server min protocol = SMB2 + server max protocol = SMB3 + ntlm auth = ntlmv2-only + map to guest = Never + usershare allow guests = no + server signing = mandatory + smb encrypt = desired + disable netbios = yes + smb ports = 445 + log level = 1 + bind interfaces only = no + +[data] + path = /share + read only = no + browsable = yes + valid users = hop + force user = hop + create mask = 0644 + directory mask = 0755 diff --git a/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/MemorySmbShare.java b/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/MemorySmbShare.java new file mode 100644 index 00000000000..bc708e3c56a --- /dev/null +++ b/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/MemorySmbShare.java @@ -0,0 +1,305 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.hop.vfs.smb; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** In-memory share for unit tests. Paths use the same {@code \} form as the smbj implementation. */ +final class MemorySmbShare implements SmbShare { + private final Map nodes = new ConcurrentHashMap<>(); + private final AtomicInteger openHandles = new AtomicInteger(); + + MemorySmbShare() { + nodes.put("", Node.directory()); + } + + int openHandles() { + return openHandles.get(); + } + + void deny(String sharePath) { + Node node = nodes.get(sharePath); + if (node == null) { + node = Node.file(); + nodes.put(sharePath, node); + } + node.denied = true; + } + + byte[] bytes(String sharePath) { + Node node = nodes.get(sharePath); + return node == null ? null : Arrays.copyOf(node.data, node.data.length); + } + + @Override + public SmbEntry stat(String sharePath) throws IOException { + Node node = nodes.get(sharePath == null ? "" : sharePath); + if (node == null) { + return null; + } + if (node.denied) { + throw new IOException("access denied"); + } + return node.entry(); + } + + @Override + public List children(String sharePath) throws IOException { + String folder = sharePath == null ? "" : sharePath; + SmbEntry entry = stat(folder); + if (entry == null) { + throw new SmbErrors.SmbNotFoundException(folder); + } + if (!entry.directory()) { + throw new IOException("not a folder"); + } + String prefix = folder.isEmpty() ? "" : folder + "\\"; + List names = new ArrayList<>(); + for (String path : nodes.keySet()) { + if (path.isEmpty() || path.equals(folder)) { + continue; + } + if (!path.startsWith(prefix)) { + continue; + } + String rest = path.substring(prefix.length()); + if (!rest.isEmpty() && !rest.contains("\\")) { + names.add(rest); + } + } + return names; + } + + @Override + public InputStream openRead(String sharePath) throws IOException { + Node node = requireFile(sharePath); + openHandles.incrementAndGet(); + byte[] copy = Arrays.copyOf(node.data, node.data.length); + return new ByteArrayInputStream(copy) { + private boolean closed; + + @Override + public void close() throws IOException { + if (!closed) { + closed = true; + openHandles.decrementAndGet(); + } + super.close(); + } + }; + } + + @Override + public OutputStream openWrite(String sharePath, boolean append) throws IOException { + Node node = ensureFile(sharePath); + if (!append) { + node.data = new byte[0]; + } + openHandles.incrementAndGet(); + ByteArrayOutputStream buffer = + new ByteArrayOutputStream() { + @Override + public void close() throws IOException { + byte[] written = toByteArray(); + if (append) { + byte[] combined = Arrays.copyOf(node.data, node.data.length + written.length); + System.arraycopy(written, 0, combined, node.data.length, written.length); + node.data = combined; + } else { + node.data = written; + } + node.modified = System.currentTimeMillis(); + openHandles.decrementAndGet(); + super.close(); + } + }; + if (append && node.data.length > 0) { + buffer.write(node.data); + node.data = new byte[0]; + } + return buffer; + } + + @Override + public SmbRandom openRandom(String sharePath, boolean write) throws IOException { + Node node = write ? ensureFile(sharePath) : requireFile(sharePath); + openHandles.incrementAndGet(); + return new SmbRandom() { + private boolean closed; + + @Override + public int read(byte[] buffer, int offset, int length, long fileOffset) { + if (fileOffset >= node.data.length) { + return -1; + } + int available = (int) Math.min(length, node.data.length - fileOffset); + System.arraycopy(node.data, (int) fileOffset, buffer, offset, available); + return available; + } + + @Override + public void write(byte[] buffer, int offset, int length, long fileOffset) { + int end = (int) fileOffset + length; + if (end > node.data.length) { + node.data = Arrays.copyOf(node.data, end); + } + System.arraycopy(buffer, offset, node.data, (int) fileOffset, length); + node.modified = System.currentTimeMillis(); + } + + @Override + public long length() { + return node.data.length; + } + + @Override + public void setLength(long length) { + node.data = Arrays.copyOf(node.data, (int) length); + } + + @Override + public void close() { + if (!closed) { + closed = true; + openHandles.decrementAndGet(); + } + } + }; + } + + @Override + public void createFolder(String sharePath) throws IOException { + mkdirs(sharePath); + } + + @Override + public void delete(String sharePath) throws IOException { + if (sharePath == null || sharePath.isEmpty()) { + throw new IOException("Cannot delete the root of an SMB share"); + } + SmbEntry entry = stat(sharePath); + if (entry == null) { + return; + } + if (entry.directory()) { + String prefix = sharePath + "\\"; + for (String path : nodes.keySet()) { + if (path.startsWith(prefix)) { + throw new IOException("folder is not empty"); + } + } + } + nodes.remove(sharePath); + } + + @Override + public void rename(String sharePath, String newSharePath) throws IOException { + Node node = nodes.get(sharePath); + if (node == null || node.denied) { + throw new SmbErrors.SmbNotFoundException(sharePath); + } + nodes.remove(sharePath); + nodes.put(newSharePath, node); + String prefix = sharePath + "\\"; + List children = new ArrayList<>(); + for (String path : nodes.keySet()) { + if (path.startsWith(prefix)) { + children.add(path); + } + } + for (String path : children) { + Node child = nodes.remove(path); + nodes.put(newSharePath + path.substring(sharePath.length()), child); + } + } + + @Override + public void close() { + // Nothing to release. + } + + private Node requireFile(String sharePath) throws IOException { + SmbEntry entry = stat(sharePath); + if (entry == null) { + throw new SmbErrors.SmbNotFoundException(sharePath); + } + if (entry.directory()) { + throw new IOException("not a file"); + } + return nodes.get(sharePath); + } + + private Node ensureFile(String sharePath) throws IOException { + mkdirs(parent(sharePath)); + Node existing = nodes.get(sharePath); + if (existing != null && existing.denied) { + throw new IOException("access denied"); + } + if (existing != null && existing.directory) { + throw new IOException("not a file"); + } + if (existing == null) { + existing = Node.file(); + nodes.put(sharePath, existing); + } + return existing; + } + + private void mkdirs(String sharePath) { + if (sharePath == null || sharePath.isEmpty() || nodes.containsKey(sharePath)) { + return; + } + mkdirs(parent(sharePath)); + nodes.put(sharePath, Node.directory()); + } + + private static String parent(String sharePath) { + int slash = sharePath.lastIndexOf('\\'); + return slash < 0 ? "" : sharePath.substring(0, slash); + } + + private static final class Node { + private boolean directory; + private byte[] data = new byte[0]; + private long modified = 1_700_000_000_000L; + private boolean denied; + + private static Node directory() { + Node node = new Node(); + node.directory = true; + return node; + } + + private static Node file() { + return new Node(); + } + + private SmbEntry entry() { + return new SmbEntry(directory, directory ? 0 : data.length, modified); + } + } +} diff --git a/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbConnectionSerializerTest.java b/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbConnectionSerializerTest.java new file mode 100644 index 00000000000..a60d2e45f70 --- /dev/null +++ b/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbConnectionSerializerTest.java @@ -0,0 +1,94 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.hop.vfs.smb; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import org.apache.hop.core.encryption.Encr; +import org.apache.hop.core.encryption.HopTwoWayPasswordEncoder; +import org.apache.hop.core.exception.HopException; +import org.apache.hop.core.variables.Variables; +import org.apache.hop.metadata.serializer.json.JsonMetadataProvider; +import org.apache.hop.vfs.smb.metadata.SmbConnection; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class SmbConnectionSerializerTest { + + @BeforeAll + static void initPasswords() throws HopException { + Encr.init("Hop"); + } + + @Test + void roundTripEncryptsThePassword(@TempDir Path folder) throws Exception { + JsonMetadataProvider provider = + new JsonMetadataProvider( + new HopTwoWayPasswordEncoder(), folder.toString(), new Variables()); + SmbConnection connection = new SmbConnection(); + connection.setName("finance"); + connection.setDescription("nightly"); + connection.setHostname("files.example"); + connection.setPort("445"); + connection.setShare("data"); + connection.setBasePath("restricted"); + connection.setAuthType(SmbAuthType.NTLM); + connection.setDomain("CORP"); + connection.setUsername("alice"); + connection.setPassword("super-secret-value"); + connection.setMinimumDialect(SmbDialect.SMB_3_1_1); + connection.setRequireSigning(true); + connection.setEncryptData(true); + connection.setDfsEnabled(true); + connection.setCallTimeoutSeconds("30"); + connection.setSocketTimeoutSeconds("20"); + provider.getSerializer(SmbConnection.class).save(connection); + + String stored = Files.readString(findJson(folder), StandardCharsets.UTF_8); + assertFalse(stored.contains("super-secret-value")); + + SmbConnection loaded = provider.getSerializer(SmbConnection.class).load("finance"); + assertEquals("nightly", loaded.getDescription()); + assertEquals("files.example", loaded.getHostname()); + assertEquals("445", loaded.getPort()); + assertEquals("data", loaded.getShare()); + assertEquals("restricted", loaded.getBasePath()); + assertEquals(SmbAuthType.NTLM, loaded.getAuthType()); + assertEquals("CORP", loaded.getDomain()); + assertEquals("alice", loaded.getUsername()); + assertEquals( + "super-secret-value", Encr.decryptPasswordOptionallyEncrypted(loaded.getPassword())); + assertEquals(SmbDialect.SMB_3_1_1, loaded.getMinimumDialect()); + assertTrue(loaded.isRequireSigning()); + assertTrue(loaded.isEncryptData()); + assertTrue(loaded.isDfsEnabled()); + assertEquals("30", loaded.getCallTimeoutSeconds()); + assertEquals("20", loaded.getSocketTimeoutSeconds()); + } + + private static Path findJson(Path folder) throws Exception { + try (var files = Files.walk(folder)) { + return files.filter(path -> path.toString().endsWith(".json")).findFirst().orElseThrow(); + } + } +} diff --git a/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbFileObjectTest.java b/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbFileObjectTest.java new file mode 100644 index 00000000000..0242ac9fb90 --- /dev/null +++ b/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbFileObjectTest.java @@ -0,0 +1,200 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.hop.vfs.smb; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import org.apache.commons.vfs2.FileObject; +import org.apache.commons.vfs2.FileSystemException; +import org.apache.commons.vfs2.RandomAccessContent; +import org.apache.commons.vfs2.impl.DefaultFileSystemManager; +import org.apache.commons.vfs2.util.RandomAccessMode; +import org.junit.jupiter.api.Test; + +class SmbFileObjectTest { + + @Test + void readsWritesListsRenamesAndDeletes() throws Exception { + MemorySmbShare share = new MemorySmbShare(); + try (Opened manager = opened(share, "")) { + FileObject folder = manager.resolveFile("finance:///reports"); + folder.createFolder(); + FileObject file = manager.resolveFile("finance:///reports/daily.csv"); + file.createFile(); + assertEquals(null, share.bytes("reports\\daily.csv")); + write(file, "alpha"); + assertArrayEquals(bytes("alpha"), share.bytes("reports\\daily.csv")); + try (InputStream in = file.getContent().getInputStream()) { + assertEquals("alpha", new String(in.readAllBytes(), StandardCharsets.UTF_8)); + } + assertEquals(0, share.openHandles()); + file.refresh(); + assertTrue(file.getContent().getLastModifiedTime() > 0); + + write(file, "beta"); + try (InputStream in = file.getContent().getInputStream()) { + assertEquals("beta", new String(in.readAllBytes(), StandardCharsets.UTF_8)); + } + try (OutputStream out = file.getContent().getOutputStream(true)) { + out.write(bytes("!")); + } + try (InputStream in = + manager.resolveFile("finance:///reports/daily.csv").getContent().getInputStream()) { + assertEquals("beta!", new String(in.readAllBytes(), StandardCharsets.UTF_8)); + } + + FileObject[] children = folder.getChildren(); + assertEquals(1, children.length); + assertTrue(children[0].getName().getBaseName().equals("daily.csv")); + + FileObject renamed = manager.resolveFile("finance:///reports/moved.csv"); + file.moveTo(renamed); + assertFalse(manager.resolveFile("finance:///reports/daily.csv").exists()); + assertTrue(renamed.exists()); + renamed.delete(); + assertFalse(renamed.exists()); + } + } + + @Test + void randomAccessReadsAndWritesAtAnOffset() throws Exception { + MemorySmbShare share = new MemorySmbShare(); + try (Opened manager = opened(share, "")) { + FileObject file = manager.resolveFile("finance:///note.txt"); + write(file, "abcd"); + try (RandomAccessContent content = + file.getContent().getRandomAccessContent(RandomAccessMode.READ)) { + content.seek(1); + assertEquals('b', content.readByte()); + } + assertEquals(0, share.openHandles()); + try (RandomAccessContent content = + file.getContent().getRandomAccessContent(RandomAccessMode.READWRITE)) { + content.seek(1); + content.write('Z'); + } + assertArrayEquals(bytes("aZcd"), share.bytes("note.txt")); + assertEquals(0, share.openHandles()); + } + } + + @Test + void missingFileIsImaginaryAndAccessDeniedIsNot() throws Exception { + MemorySmbShare share = new MemorySmbShare(); + share.deny("secret.txt"); + try (Opened manager = opened(share, "")) { + assertFalse(manager.resolveFile("finance:///missing.txt").exists()); + FileObject denied = manager.resolveFile("finance:///secret.txt"); + assertThrows(FileSystemException.class, denied::exists); + } + } + + @Test + void basePathKeepsFilesInsideTheFolder() throws Exception { + MemorySmbShare share = new MemorySmbShare(); + try (Opened rooted = opened(share, "restricted")) { + FileObject file = rooted.resolveFile("finance:///ok.txt"); + write(file, "inside"); + assertArrayEquals(bytes("inside"), share.bytes("restricted\\ok.txt")); + assertEquals(null, share.bytes("ok.txt")); + assertThrows( + FileSystemException.class, () -> rooted.resolveFile("finance:///../../climbed.txt")); + assertEquals(null, share.bytes("climbed.txt")); + } + } + + @Test + void twoThreadsWriteDifferentFilesOnOneShare() throws Exception { + MemorySmbShare share = new MemorySmbShare(); + try (Opened manager = opened(share, "")) { + ExecutorService pool = Executors.newFixedThreadPool(2); + CountDownLatch start = new CountDownLatch(1); + try { + Future first = + pool.submit( + () -> { + start.await(); + write(manager.resolveFile("finance:///a.txt"), "one"); + return null; + }); + Future second = + pool.submit( + () -> { + start.await(); + write(manager.resolveFile("finance:///b.txt"), "two"); + return null; + }); + start.countDown(); + first.get(); + second.get(); + } finally { + pool.shutdownNow(); + } + assertArrayEquals(bytes("one"), share.bytes("a.txt")); + assertArrayEquals(bytes("two"), share.bytes("b.txt")); + } + } + + private static Opened opened(MemorySmbShare share, String base) throws Exception { + DefaultFileSystemManager manager = new DefaultFileSystemManager(); + manager.addProvider("finance", new SmbFileProvider(share, base)); + manager.init(); + return new Opened(manager); + } + + private static final class Opened implements AutoCloseable { + private final DefaultFileSystemManager manager; + + private Opened(DefaultFileSystemManager manager) { + this.manager = manager; + } + + private FileObject resolveFile(String uri) throws FileSystemException { + return manager.resolveFile(uri); + } + + @Override + public void close() { + manager.close(); + } + } + + private static void write(FileObject file, String text) throws Exception { + if (file.exists()) { + file.delete(); + } + file.createFile(); + try (OutputStream out = file.getContent().getOutputStream()) { + out.write(bytes(text)); + } + } + + private static byte[] bytes(String text) { + return text.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbSupportTest.java b/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbSupportTest.java new file mode 100644 index 00000000000..46a83f99d20 --- /dev/null +++ b/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbSupportTest.java @@ -0,0 +1,184 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.hop.vfs.smb; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.hierynomus.mssmb2.SMB2Dialect; +import com.hierynomus.smbj.SmbConfig; +import com.hierynomus.smbj.auth.AuthenticationContext; +import java.util.Set; +import org.apache.commons.vfs2.FileType; +import org.apache.hop.core.encryption.Encr; +import org.apache.hop.core.exception.HopException; +import org.apache.hop.core.variables.Variables; +import org.apache.hop.vfs.smb.metadata.SmbConnection; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class SmbSupportTest { + + @BeforeAll + static void initPasswords() throws HopException { + Encr.init("Hop"); + } + + @Test + void pluginHasNoFixedScheme() { + SmbVfsPlugin plugin = new SmbVfsPlugin(); + assertEquals(0, plugin.getUrlSchemes().length); + assertEquals(null, plugin.getProvider()); + } + + @Test + void parsesNamedConnectionUri() throws Exception { + SmbFileName name = + (SmbFileName) + SmbFileNameParser.getInstance() + .parseUri(null, null, "finance:///reports/2026/daily.csv"); + assertEquals("finance", name.getScheme()); + assertEquals("/reports/2026/daily.csv", name.getPath()); + assertEquals(FileType.FILE, name.getType()); + assertEquals("finance:///", name.getRootURI()); + } + + @Test + void ntlmIdentity() { + assertEquals(new SmbIdentity("", "alice"), SmbIdentity.parse("", "alice")); + assertEquals(new SmbIdentity("CORP", "alice"), SmbIdentity.parse("", "CORP\\alice")); + assertEquals(new SmbIdentity("CORP", "alice"), SmbIdentity.parse("CORP", "OTHER\\alice")); + assertEquals( + new SmbIdentity("", "alice@corp.example"), SmbIdentity.parse("", "alice@corp.example")); + } + + @Test + void ntlmContextResolvesPasswordVariableAndOmitsItFromErrors() { + Variables variables = new Variables(); + variables.setVariable("SMB_PASSWORD", "super-secret-value"); + SmbConnection connection = connection(); + connection.setUsername("CORP\\alice"); + connection.setPassword("${SMB_PASSWORD}"); + AuthenticationContext context = + SmbAuthenticators.forType(SmbAuthType.NTLM).authenticate(connection, variables); + assertEquals("alice", context.getUsername()); + assertEquals("CORP", context.getDomain()); + assertArrayEquals("super-secret-value".toCharArray(), context.getPassword()); + assertFalse(context.toString().contains("super-secret-value")); + + connection.setUsername(" "); + IllegalArgumentException missing = + assertThrows( + IllegalArgumentException.class, + () -> SmbAuthenticators.forType(SmbAuthType.NTLM).authenticate(connection, variables)); + assertFalse(missing.getMessage().contains("super-secret-value")); + } + + @Test + void guestIgnoresUsernameAndPassword() { + SmbConnection connection = connection(); + connection.setAuthType(SmbAuthType.GUEST); + connection.setUsername("alice"); + connection.setPassword("super-secret-value"); + AuthenticationContext context = + SmbAuthenticators.forType(SmbAuthType.GUEST).authenticate(connection, new Variables()); + assertTrue(context.isGuest()); + } + + @Test + void nullAuthTypeIsNtlm() { + SmbConnection connection = connection(); + connection.setUsername("alice"); + connection.setPassword("pw"); + AuthenticationContext context = + SmbAuthenticators.forType(null).authenticate(connection, new Variables()); + assertEquals("alice", context.getUsername()); + } + + @Test + void clientConfigUsesDialectFloorAndTimeouts() { + SmbConnection connection = connection(); + connection.setMinimumDialect(SmbDialect.SMB_3_0); + connection.setRequireSigning(true); + connection.setEncryptData(true); + connection.setDfsEnabled(true); + connection.setCallTimeoutSeconds("45"); + connection.setSocketTimeoutSeconds("15"); + SmbConfig config = SmbSettings.resolve(connection, new Variables()).toClientConfig(); + assertEquals( + Set.of(SMB2Dialect.SMB_3_0, SMB2Dialect.SMB_3_0_2, SMB2Dialect.SMB_3_1_1), + config.getSupportedDialects()); + assertFalse(config.getSupportedDialects().contains(SMB2Dialect.SMB_2_0_2)); + assertFalse(config.getSupportedDialects().contains(SMB2Dialect.UNKNOWN)); + assertTrue(config.isSigningRequired()); + assertTrue(config.isEncryptData()); + assertTrue(config.isDfsEnabled()); + assertEquals(45_000L, config.getReadTimeout()); + assertEquals(45_000L, config.getWriteTimeout()); + assertEquals(15_000, config.getSoTimeout()); + } + + @Test + void defaultDialectStartsAtSmb2() { + SmbConfig config = SmbSettings.resolve(connection(), new Variables()).toClientConfig(); + assertTrue(config.getSupportedDialects().contains(SMB2Dialect.SMB_2_0_2)); + assertTrue(config.getSupportedDialects().contains(SMB2Dialect.SMB_3_1_1)); + assertFalse(config.isSigningRequired()); + assertFalse(config.isEncryptData()); + assertFalse(config.isDfsEnabled()); + } + + @Test + void rejectsBlankHostShareAndNonPositiveTimeout() { + Variables variables = new Variables(); + SmbConnection connection = connection(); + connection.setHostname(" "); + assertThrows(IllegalArgumentException.class, () -> SmbSettings.resolve(connection, variables)); + connection.setHostname("files"); + connection.setShare(""); + assertThrows(IllegalArgumentException.class, () -> SmbSettings.resolve(connection, variables)); + connection.setShare("data"); + connection.setPort("0"); + IllegalArgumentException port = + assertThrows( + IllegalArgumentException.class, () -> SmbSettings.resolve(connection, variables)); + assertFalse(port.getMessage().contains("super-secret-value")); + } + + @Test + void sharePathStaysUnderTheBaseFolder() throws Exception { + assertEquals("reports\\daily.csv", SmbPaths.sharePath("", "/reports/daily.csv")); + assertEquals( + "restricted\\reports\\daily.csv", SmbPaths.sharePath("restricted", "/reports/daily.csv")); + assertEquals("restricted", SmbPaths.sharePath("/restricted/", "/")); + assertThrows(Exception.class, () -> SmbPaths.sharePath("", "/../outside.txt")); + assertThrows(Exception.class, () -> SmbPaths.sharePath("restricted", "/../../outside.txt")); + } + + private static SmbConnection connection() { + SmbConnection connection = new SmbConnection(); + connection.setName("finance"); + connection.setHostname("files.example"); + connection.setShare("data"); + connection.setUsername("alice"); + connection.setPassword("pw"); + return connection; + } +} diff --git a/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbVfsContainerIT.java b/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbVfsContainerIT.java new file mode 100644 index 00000000000..7ffc9c38988 --- /dev/null +++ b/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbVfsContainerIT.java @@ -0,0 +1,252 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.hop.vfs.smb; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import org.apache.commons.vfs2.FileObject; +import org.apache.commons.vfs2.FileSystemException; +import org.apache.commons.vfs2.RandomAccessContent; +import org.apache.commons.vfs2.impl.DefaultFileSystemManager; +import org.apache.commons.vfs2.util.RandomAccessMode; +import org.apache.hop.core.encryption.Encr; +import org.apache.hop.core.exception.HopException; +import org.apache.hop.core.logging.HopLogStore; +import org.apache.hop.core.variables.Variables; +import org.apache.hop.vfs.smb.metadata.SmbConnection; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.builder.ImageFromDockerfile; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Samba with SMB1 disabled, NTLMv2 only, and mandatory signing. A second container requires SMB3 + * encryption. + */ +@Testcontainers(disabledWithoutDocker = true) +class SmbVfsContainerIT { + + private static final String PASSWORD = "hop-it-password"; + private static final Path DOCKER = Path.of("src/test/docker").toAbsolutePath(); + + private static final ImageFromDockerfile IMAGE = + new ImageFromDockerfile("hop-smb-it", false) + .withDockerfilePath("Dockerfile") + .withFileFromPath("Dockerfile", DOCKER.resolve("Dockerfile")) + .withFileFromPath("smb.conf", DOCKER.resolve("smb.conf")) + .withFileFromPath("entrypoint.sh", DOCKER.resolve("entrypoint.sh")); + + @Container static final GenericContainer samba = container(false); + + @Container static final GenericContainer encrypted = container(true); + + @BeforeAll + static void initPasswords() throws HopException { + HopLogStore.init(); + Encr.init("Hop"); + } + + @Test + void readsAndWritesThroughTheNamedConnection() throws Exception { + try (Opened manager = + opened( + connection("rooted", samba, "restricted"), "share", connection("share", samba, ""))) { + FileObject folder = manager.resolveFile("rooted:///reports"); + folder.createFolder(); + FileObject file = manager.resolveFile("rooted:///reports/daily.csv"); + write(file, "alpha"); + try (InputStream in = file.getContent().getInputStream()) { + assertEquals("alpha", new String(in.readAllBytes(), StandardCharsets.UTF_8)); + } + file.refresh(); + assertTrue(file.getContent().getLastModifiedTime() > 0); + + write(file, "beta"); + try (OutputStream out = file.getContent().getOutputStream(true)) { + out.write("!".getBytes(StandardCharsets.UTF_8)); + } + try (InputStream in = + manager.resolveFile("rooted:///reports/daily.csv").getContent().getInputStream()) { + assertEquals("beta!", new String(in.readAllBytes(), StandardCharsets.UTF_8)); + } + try (RandomAccessContent content = + file.getContent().getRandomAccessContent(RandomAccessMode.READ)) { + content.seek(1); + assertEquals('e', content.readByte()); + } + + assertEquals(1, folder.getChildren().length); + FileObject renamed = manager.resolveFile("rooted:///reports/moved.csv"); + file.moveTo(renamed); + assertFalse(manager.resolveFile("rooted:///reports/daily.csv").exists()); + assertTrue(renamed.exists()); + + assertFalse(manager.resolveFile("share:///reports/moved.csv").exists()); + assertTrue(manager.resolveFile("share:///restricted/reports/moved.csv").exists()); + assertFalse(manager.resolveFile("share:///ok-at-root.txt").exists()); + + assertThrows( + FileSystemException.class, () -> manager.resolveFile("rooted:///../../climbed.txt")); + assertFalse(manager.resolveFile("share:///climbed.txt").exists()); + + renamed.delete(); + assertFalse(renamed.exists()); + } + } + + @Test + void twoThreadsShareOneSession() throws Exception { + try (Opened manager = opened(connection("finance", samba, ""), null, null)) { + FileObject first = manager.resolveFile("finance:///one.txt"); + write(first, "one"); + ExecutorService pool = Executors.newFixedThreadPool(2); + CountDownLatch start = new CountDownLatch(1); + try (InputStream open = first.getContent().getInputStream()) { + Future reader = + pool.submit( + () -> { + start.await(); + return open.readAllBytes(); + }); + Future writer = + pool.submit( + () -> { + start.await(); + write(manager.resolveFile("finance:///two.txt"), "two"); + return null; + }); + start.countDown(); + assertEquals("one", new String((byte[]) reader.get(), StandardCharsets.UTF_8)); + writer.get(); + } finally { + pool.shutdownNow(); + } + try (InputStream in = + manager.resolveFile("finance:///two.txt").getContent().getInputStream()) { + assertEquals("two", new String(in.readAllBytes(), StandardCharsets.UTF_8)); + } + } + } + + @Test + void wrongPasswordDoesNotCreateAFile() throws Exception { + SmbConnection connection = connection("finance", samba, ""); + connection.setPassword("not-the-password"); + try (Opened manager = opened(connection, null, null)) { + FileObject file = manager.resolveFile("finance:///denied.txt"); + FileSystemException error = + assertThrows(FileSystemException.class, () -> write(file, "nope")); + assertFalse(error.getMessage().contains(PASSWORD)); + assertFalse(String.valueOf(error.getCause()).contains(PASSWORD)); + } + } + + @Test + void encryptedShareRoundTrips() throws Exception { + SmbConnection connection = connection("secure", encrypted, ""); + connection.setEncryptData(true); + try (Opened manager = opened(connection, null, null)) { + FileObject file = manager.resolveFile("secure:///secret.txt"); + write(file, "cipher"); + try (InputStream in = file.getContent().getInputStream()) { + assertEquals("cipher", new String(in.readAllBytes(), StandardCharsets.UTF_8)); + } + } + } + + private static GenericContainer container(boolean encrypt) { + GenericContainer container = + new GenericContainer<>(IMAGE) + .withExposedPorts(445) + .withEnv("SMB_PASSWORD", PASSWORD) + .waitingFor(Wait.forListeningPort().withStartupTimeout(Duration.ofMinutes(3))); + if (encrypt) { + container.withEnv("SMB_ENCRYPT", "required"); + } + return container; + } + + private static SmbConnection connection( + String name, GenericContainer server, String basePath) { + SmbConnection connection = new SmbConnection(); + connection.setName(name); + connection.setHostname(server.getHost()); + connection.setPort(Integer.toString(server.getMappedPort(445))); + connection.setShare("data"); + connection.setBasePath(basePath); + connection.setAuthType(SmbAuthType.NTLM); + connection.setUsername("hop"); + connection.setPassword("${SMB_PASSWORD}"); + connection.setRequireSigning(true); + return connection; + } + + private static Opened opened(SmbConnection first, String secondName, SmbConnection second) + throws Exception { + Variables variables = new Variables(); + variables.setVariable("SMB_PASSWORD", PASSWORD); + DefaultFileSystemManager manager = new DefaultFileSystemManager(); + manager.addProvider(first.getName(), new SmbFileProvider(variables, first)); + if (second != null) { + manager.addProvider(secondName, new SmbFileProvider(variables, second)); + } + manager.init(); + return new Opened(manager); + } + + private static final class Opened implements AutoCloseable { + private final DefaultFileSystemManager manager; + + private Opened(DefaultFileSystemManager manager) { + this.manager = manager; + } + + private FileObject resolveFile(String uri) throws FileSystemException { + return manager.resolveFile(uri); + } + + @Override + public void close() { + manager.close(); + } + } + + private static void write(FileObject file, String text) throws Exception { + if (file.exists()) { + file.delete(); + } + file.createFile(); + try (OutputStream out = file.getContent().getOutputStream()) { + out.write(text.getBytes(StandardCharsets.UTF_8)); + } + } +} From 94870b35fac4bf3fcb741d9ab60f8bafaa317ace Mon Sep 17 00:00:00 2001 From: mattcasters Date: Thu, 1 Oct 2026 20:53:36 +0200 Subject: [PATCH 2/4] Issue #8671 : Cover SMB with the Jenkins integration suite Run create, write, read, append, list, copy, move, and delete against Samba from integration-tests/smb, including a rooted folder, SMB3 encryption, a bad password, and a missing share. Drop the Testcontainers image. Relative paths resolve against the parent, a move creates missing folders, and the share root cannot be deleted. --- .../integration-tests-smb.yaml | 53 ++++ .../resource/smb}/Dockerfile | 2 +- .../resource/smb}/entrypoint.sh | 0 .../integration-tests/resource/smb}/smb.conf | 0 .../images/metadata-types/smb-connection.png | Bin 0 -> 86512 bytes .../pages/metadata-types/smb-connection.adoc | 9 +- integration-tests/smb/dev-env-config.json | 31 ++ integration-tests/smb/hop-config.json | 290 ++++++++++++++++++ .../smb/main-001-create-folder.hwf | 54 ++++ .../smb/main-002-write-and-read.hwf | 96 ++++++ integration-tests/smb/main-003-append.hwf | 125 ++++++++ integration-tests/smb/main-004-list.hwf | 100 ++++++ integration-tests/smb/main-005-copy.hwf | 125 ++++++++ integration-tests/smb/main-006-move.hwf | 156 ++++++++++ .../smb/main-007-delete-file.hwf | 133 ++++++++ .../smb/main-008-delete-folder.hwf | 91 ++++++ integration-tests/smb/main-009-rooted.hwf | 97 ++++++ .../smb/main-010-smb3-encryption.hwf | 96 ++++++ .../smb/main-011-bad-password.hwf | 111 +++++++ .../smb/main-012-missing-share.hwf | 96 ++++++ integration-tests/smb/main-013-two-copies.hwf | 97 ++++++ .../pipeline-run-configuration/local.json | 22 ++ .../metadata/smb-connection/finance-bad.json | 18 ++ .../smb-connection/finance-encrypted.json | 18 ++ .../smb-connection/finance-missing.json | 18 ++ .../smb-connection/finance-rooted.json | 18 ++ .../smb/metadata/smb-connection/finance.json | 18 ++ .../workflow-run-configuration/local.json | 11 + integration-tests/smb/project-config.json | 13 + integration-tests/vfs/append-line.hpl | 116 +++++++ integration-tests/vfs/disabled.txt | 1 + integration-tests/vfs/expect-alpha-beta.hpl | 272 ++++++++++++++++ integration-tests/vfs/expect-alpha.hpl | 272 ++++++++++++++++ integration-tests/vfs/list-one.hpl | 174 +++++++++++ integration-tests/vfs/write-line.hpl | 116 +++++++ integration-tests/vfs/write-two.hpl | 167 ++++++++++ plugins/tech/smb/pom.xml | 32 -- .../hop/vfs/smb/SmbConnectionTester.java | 2 +- .../org/apache/hop/vfs/smb/SmbErrors.java | 12 + .../apache/hop/vfs/smb/SmbFileNameParser.java | 9 + .../org/apache/hop/vfs/smb/SmbFileObject.java | 15 +- .../hop/vfs/smb/SmbRandomAccessContent.java | 20 -- .../org/apache/hop/vfs/smb/SmbjSmbShare.java | 11 + .../hop/vfs/smb/metadata/SmbConnection.java | 3 + .../vfs/smb/metadata/SmbConnectionEditor.java | 2 +- .../apache/hop/vfs/smb/MemorySmbShare.java | 1 + .../apache/hop/vfs/smb/SmbFileObjectTest.java | 11 +- .../apache/hop/vfs/smb/SmbSupportTest.java | 25 ++ .../apache/hop/vfs/smb/SmbVfsContainerIT.java | 252 --------------- 49 files changed, 3089 insertions(+), 322 deletions(-) create mode 100644 docker/integration-tests/integration-tests-smb.yaml rename {plugins/tech/smb/src/test/docker => docker/integration-tests/resource/smb}/Dockerfile (93%) rename {plugins/tech/smb/src/test/docker => docker/integration-tests/resource/smb}/entrypoint.sh (100%) rename {plugins/tech/smb/src/test/docker => docker/integration-tests/resource/smb}/smb.conf (100%) create mode 100644 docs/hop-user-manual/modules/ROOT/assets/images/metadata-types/smb-connection.png create mode 100644 integration-tests/smb/dev-env-config.json create mode 100644 integration-tests/smb/hop-config.json create mode 100644 integration-tests/smb/main-001-create-folder.hwf create mode 100644 integration-tests/smb/main-002-write-and-read.hwf create mode 100644 integration-tests/smb/main-003-append.hwf create mode 100644 integration-tests/smb/main-004-list.hwf create mode 100644 integration-tests/smb/main-005-copy.hwf create mode 100644 integration-tests/smb/main-006-move.hwf create mode 100644 integration-tests/smb/main-007-delete-file.hwf create mode 100644 integration-tests/smb/main-008-delete-folder.hwf create mode 100644 integration-tests/smb/main-009-rooted.hwf create mode 100644 integration-tests/smb/main-010-smb3-encryption.hwf create mode 100644 integration-tests/smb/main-011-bad-password.hwf create mode 100644 integration-tests/smb/main-012-missing-share.hwf create mode 100644 integration-tests/smb/main-013-two-copies.hwf create mode 100644 integration-tests/smb/metadata/pipeline-run-configuration/local.json create mode 100644 integration-tests/smb/metadata/smb-connection/finance-bad.json create mode 100644 integration-tests/smb/metadata/smb-connection/finance-encrypted.json create mode 100644 integration-tests/smb/metadata/smb-connection/finance-missing.json create mode 100644 integration-tests/smb/metadata/smb-connection/finance-rooted.json create mode 100644 integration-tests/smb/metadata/smb-connection/finance.json create mode 100644 integration-tests/smb/metadata/workflow-run-configuration/local.json create mode 100644 integration-tests/smb/project-config.json create mode 100644 integration-tests/vfs/append-line.hpl create mode 100644 integration-tests/vfs/disabled.txt create mode 100644 integration-tests/vfs/expect-alpha-beta.hpl create mode 100644 integration-tests/vfs/expect-alpha.hpl create mode 100644 integration-tests/vfs/list-one.hpl create mode 100644 integration-tests/vfs/write-line.hpl create mode 100644 integration-tests/vfs/write-two.hpl delete mode 100644 plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbVfsContainerIT.java diff --git a/docker/integration-tests/integration-tests-smb.yaml b/docker/integration-tests/integration-tests-smb.yaml new file mode 100644 index 00000000000..72b66026dc7 --- /dev/null +++ b/docker/integration-tests/integration-tests-smb.yaml @@ -0,0 +1,53 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +services: + integration_test_smb: + extends: + file: integration-tests-base.yaml + service: integration_test + depends_on: + samba: + condition: service_healthy + samba-encrypted: + condition: service_healthy + + samba: + build: + context: ./resource/smb + hostname: samba + environment: + SMB_PASSWORD: hop-it-password + healthcheck: + test: ["CMD", "smbclient", "-L", "127.0.0.1", "-U", "hop%hop-it-password", "-m", "SMB3"] + interval: 5s + timeout: 10s + retries: 20 + start_period: 5s + + samba-encrypted: + build: + context: ./resource/smb + hostname: samba-encrypted + environment: + SMB_PASSWORD: hop-it-password + SMB_ENCRYPT: required + healthcheck: + test: ["CMD", "smbclient", "-L", "127.0.0.1", "-U", "hop%hop-it-password", "-m", "SMB3"] + interval: 5s + timeout: 10s + retries: 20 + start_period: 5s diff --git a/plugins/tech/smb/src/test/docker/Dockerfile b/docker/integration-tests/resource/smb/Dockerfile similarity index 93% rename from plugins/tech/smb/src/test/docker/Dockerfile rename to docker/integration-tests/resource/smb/Dockerfile index 3bef9c30d7c..3295b31953c 100644 --- a/plugins/tech/smb/src/test/docker/Dockerfile +++ b/docker/integration-tests/resource/smb/Dockerfile @@ -16,7 +16,7 @@ FROM debian:bookworm-slim RUN apt-get update \ - && apt-get install -y --no-install-recommends samba \ + && apt-get install -y --no-install-recommends samba smbclient \ && rm -rf /var/lib/apt/lists/* COPY smb.conf /etc/samba/smb.conf diff --git a/plugins/tech/smb/src/test/docker/entrypoint.sh b/docker/integration-tests/resource/smb/entrypoint.sh similarity index 100% rename from plugins/tech/smb/src/test/docker/entrypoint.sh rename to docker/integration-tests/resource/smb/entrypoint.sh diff --git a/plugins/tech/smb/src/test/docker/smb.conf b/docker/integration-tests/resource/smb/smb.conf similarity index 100% rename from plugins/tech/smb/src/test/docker/smb.conf rename to docker/integration-tests/resource/smb/smb.conf diff --git a/docs/hop-user-manual/modules/ROOT/assets/images/metadata-types/smb-connection.png b/docs/hop-user-manual/modules/ROOT/assets/images/metadata-types/smb-connection.png new file mode 100644 index 0000000000000000000000000000000000000000..68ae839a55cf74f5a94efd379df7cb2be5c84171 GIT binary patch literal 86512 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 z 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 literal 0 HcmV?d00001 diff --git a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/smb-connection.adoc b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/smb-connection.adoc index 6f455a81c56..2cdbfa8d148 100644 --- a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/smb-connection.adoc +++ b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/smb-connection.adoc @@ -30,12 +30,19 @@ Nothing is mounted on the operating system, and SMB1 is not used. See xref:vfs/smb-vfs.adoc[SMB] for how paths are written. +image::metadata-types/smb-connection.png[SMB connection editor on the Server tab, with name finance, width="90%"] + +The name at the top of the editor is the URL scheme. +The tabs are Server, Authentication, and Security. +*Test connection* opens the share with the current settings. + == Server [options="header"] |=== |Option|Description -|Hostname|Server name. For Azure Files this is `account.file.core.windows.net`. +|Description|Optional note about this connection. It is not part of the URL. +|Hostname|Server name. A variable such as `${FINANCE_FILE_SERVER}` is resolved at runtime. For Azure Files this is `account.file.core.windows.net`. |Port|TCP port. Default `445`. |Share|Share name, without a leading slash. |Folder in the share|Optional folder. Every path on this connection stays under that folder. diff --git a/integration-tests/smb/dev-env-config.json b/integration-tests/smb/dev-env-config.json new file mode 100644 index 00000000000..1be5b7de0f1 --- /dev/null +++ b/integration-tests/smb/dev-env-config.json @@ -0,0 +1,31 @@ +{ + "variables" : [ { + "name" : "SMB_HOSTNAME", + "value" : "samba", + "description" : "Compose service name of the signing Samba server" + }, { + "name" : "SMB_ENCRYPTED_HOSTNAME", + "value" : "samba-encrypted", + "description" : "Compose service name of the Samba server that requires encryption" + }, { + "name" : "SMB_PORT", + "value" : "445", + "description" : "" + }, { + "name" : "SMB_USER", + "value" : "hop", + "description" : "" + }, { + "name" : "SMB_PASSWORD", + "value" : "hop-it-password", + "description" : "Matches the Samba container. A variable is not encrypted into the connection JSON." + }, { + "name" : "SMB_BAD_PASSWORD", + "value" : "not-the-password", + "description" : "" + }, { + "name" : "SMB_SHARE", + "value" : "data", + "description" : "" + } ] +} diff --git a/integration-tests/smb/hop-config.json b/integration-tests/smb/hop-config.json new file mode 100644 index 00000000000..102ac981d58 --- /dev/null +++ b/integration-tests/smb/hop-config.json @@ -0,0 +1,290 @@ +{ + "variables": [ + { + "name": "HOP_LENIENT_STRING_TO_NUMBER_CONVERSION", + "value": "N", + "description": "System wide flag to allow lenient string to number conversion for backward compatibility. If this setting is set to \"Y\", an string starting with digits will be converted successfully into a number. (example: 192.168.1.1 will be converted into 192 or 192.168 or 192168 depending on the decimal and grouping symbol). The default (N) will be to throw an error if non-numeric symbols are found in the string." + }, + { + "name": "HOP_COMPATIBILITY_DB_IGNORE_TIMEZONE", + "value": "N", + "description": "System wide flag to ignore timezone while writing date/timestamp value to the database." + }, + { + "name": "HOP_LOG_SIZE_LIMIT", + "value": "0", + "description": "The log size limit for all pipelines and workflows that don't have the \"log size limit\" property set in their respective properties." + }, + { + "name": "HOP_EMPTY_STRING_DIFFERS_FROM_NULL", + "value": "N", + "description": "NULL vs Empty String. If this setting is set to Y, an empty string and null are different. Otherwise they are not." + }, + { + "name": "HOP_MAX_LOG_SIZE_IN_LINES", + "value": "0", + "description": "The maximum number of log lines that are kept internally by Hop. Set to 0 to keep all rows (default)" + }, + { + "name": "HOP_MAX_LOG_TIMEOUT_IN_MINUTES", + "value": "1440", + "description": "The maximum age (in minutes) of a log line while being kept internally by Hop. Set to 0 to keep all rows indefinitely (default)" + }, + { + "name": "HOP_MAX_WORKFLOW_TRACKER_SIZE", + "value": "5000", + "description": "The maximum number of workflow trackers kept in memory" + }, + { + "name": "HOP_MAX_ACTIONS_LOGGED", + "value": "5000", + "description": "The maximum number of action results kept in memory for logging purposes." + }, + { + "name": "HOP_MAX_LOGGING_REGISTRY_SIZE", + "value": "10000", + "description": "The maximum number of logging registry entries kept in memory for logging purposes." + }, + { + "name": "HOP_LOG_TAB_REFRESH_DELAY", + "value": "1000", + "description": "The hop log tab refresh delay." + }, + { + "name": "HOP_LOG_TAB_REFRESH_PERIOD", + "value": "1000", + "description": "The hop log tab refresh period." + }, + { + "name": "HOP_PLUGIN_CLASSES", + "value": null, + "description": "A comma delimited list of classes to scan for plugin annotations" + }, + { + "name": "HOP_PLUGIN_PACKAGES", + "value": null, + "description": "A comma delimited list of packages to scan for plugin annotations (warning: slow!!)" + }, + { + "name": "HOP_TRANSFORM_PERFORMANCE_SNAPSHOT_LIMIT", + "value": "0", + "description": "The maximum number of transform performance snapshots to keep in memory. Set to 0 to keep all snapshots indefinitely (default)" + }, + { + "name": "HOP_ROWSET_GET_TIMEOUT", + "value": "50", + "description": "The name of the variable that optionally contains an alternative rowset get timeout (in ms). This only makes a difference for extremely short lived pipelines." + }, + { + "name": "HOP_ROWSET_PUT_TIMEOUT", + "value": "50", + "description": "The name of the variable that optionally contains an alternative rowset put timeout (in ms). This only makes a difference for extremely short lived pipelines." + }, + { + "name": "HOP_CORE_TRANSFORMS_FILE", + "value": null, + "description": "The name of the project variable that will contain the alternative location of the hop-transforms.xml file. You can use this to customize the list of available internal transforms outside of the codebase." + }, + { + "name": "HOP_CORE_WORKFLOW_ACTIONS_FILE", + "value": null, + "description": "The name of the project variable that will contain the alternative location of the hop-workflow-actions.xml file." + }, + { + "name": "HOP_SERVER_OBJECT_TIMEOUT_MINUTES", + "value": "1440", + "description": "This project variable will set a time-out after which waiting, completed or stopped pipelines and workflows will be automatically cleaned up. The default value is 1440 (one day)." + }, + { + "name": "HOP_PIPELINE_PAN_JVM_EXIT_CODE", + "value": null, + "description": "Set this variable to an integer that will be returned as the Pan JVM exit code." + }, + { + "name": "HOP_DISABLE_CONSOLE_LOGGING", + "value": "N", + "description": "Set this variable to Y to disable standard Hop logging to the console. (stdout)" + }, + { + "name": "HOP_REDIRECT_STDERR", + "value": "N", + "description": "Set this variable to Y to redirect stderr to Hop logging." + }, + { + "name": "HOP_REDIRECT_STDOUT", + "value": "N", + "description": "Set this variable to Y to redirect stdout to Hop logging." + }, + { + "name": "HOP_DEFAULT_NUMBER_FORMAT", + "value": null, + "description": "The name of the variable containing an alternative default number format" + }, + { + "name": "HOP_DEFAULT_BIGNUMBER_FORMAT", + "value": null, + "description": "The name of the variable containing an alternative default bignumber format" + }, + { + "name": "HOP_DEFAULT_INTEGER_FORMAT", + "value": null, + "description": "The name of the variable containing an alternative default integer format" + }, + { + "name": "HOP_DEFAULT_DATE_FORMAT", + "value": null, + "description": "The name of the variable containing an alternative default date format" + }, + { + "name": "HOP_DEFAULT_TIMESTAMP_FORMAT", + "value": null, + "description": "The name of the variable containing an alternative default timestamp format" + }, + { + "name": "HOP_DEFAULT_SERVLET_ENCODING", + "value": null, + "description": "Defines the default encoding for servlets, leave it empty to use Java default encoding" + }, + { + "name": "HOP_FAIL_ON_LOGGING_ERROR", + "value": "N", + "description": "Set this variable to Y when you want the workflow/pipeline fail with an error when the related logging process (e.g. to a database) fails." + }, + { + "name": "HOP_AGGREGATION_MIN_NULL_IS_VALUED", + "value": "N", + "description": "Set this variable to Y to set the minimum to NULL if NULL is within an aggregate. Otherwise by default NULL is ignored by the MIN aggregate and MIN is set to the minimum value that is not NULL. See also the variable HOP_AGGREGATION_ALL_NULLS_ARE_ZERO." + }, + { + "name": "HOP_AGGREGATION_ALL_NULLS_ARE_ZERO", + "value": "N", + "description": "Set this variable to Y to return 0 when all values within an aggregate are NULL. Otherwise by default a NULL is returned when all values are NULL." + }, + { + "name": "HOP_COMPATIBILITY_TEXT_FILE_OUTPUT_APPEND_NO_HEADER", + "value": "N", + "description": "Set this variable to Y for backward compatibility for the Text File Output transform. Setting this to Ywill add no header row at all when the append option is enabled, regardless if the file is existing or not." + }, + { + "name": "HOP_PASSWORD_ENCODER_PLUGIN", + "value": "Hop", + "description": "Specifies the password encoder plugin to use by ID (Hop is the default)." + }, + { + "name": "HOP_SYSTEM_HOSTNAME", + "value": null, + "description": "You can use this variable to speed up hostname lookup. Hostname lookup is performed by Hop so that it is capable of logging the server on which a workflow or pipeline is executed." + }, + { + "name": "HOP_SERVER_JETTY_ACCEPTORS", + "value": null, + "description": "A variable to configure jetty option: acceptors for Carte" + }, + { + "name": "HOP_SERVER_JETTY_ACCEPT_QUEUE_SIZE", + "value": null, + "description": "A variable to configure jetty option: acceptQueueSize for Carte" + }, + { + "name": "HOP_SERVER_JETTY_RES_MAX_IDLE_TIME", + "value": null, + "description": "A variable to configure jetty option: lowResourcesMaxIdleTime for Carte" + }, + { + "name": "HOP_COMPATIBILITY_MERGE_ROWS_USE_REFERENCE_STREAM_WHEN_IDENTICAL", + "value": "N", + "description": "Set this variable to Y for backward compatibility for the Merge Rows (diff) transform. Setting this to Y will use the data from the reference stream (instead of the comparison stream) in case the compared rows are identical." + }, + { + "name": "HOP_SPLIT_FIELDS_REMOVE_ENCLOSURE", + "value": "false", + "description": "Set this variable to false to preserve enclosure symbol after splitting the string in the Split fields transform. Changing it to true will remove first and last enclosure symbol from the resulting string chunks." + }, + { + "name": "HOP_ALLOW_EMPTY_FIELD_NAMES_AND_TYPES", + "value": "false", + "description": "Set this variable to TRUE to allow your pipeline to pass 'null' fields and/or empty types." + }, + { + "name": "HOP_GLOBAL_LOG_VARIABLES_CLEAR_ON_EXPORT", + "value": "false", + "description": "Set this variable to false to preserve global log variables defined in pipeline / workflow Properties -> Log panel. Changing it to true will clear it when export pipeline / workflow." + }, + { + "name": "HOP_FILE_OUTPUT_MAX_STREAM_COUNT", + "value": "1024", + "description": "This project variable is used by the Text File Output transform. It defines the max number of simultaneously open files within the transform. The transform will close/reopen files as necessary to insure the max is not exceeded" + }, + { + "name": "HOP_FILE_OUTPUT_MAX_STREAM_LIFE", + "value": "0", + "description": "This project variable is used by the Text File Output transform. It defines the max number of milliseconds between flushes of files opened by the transform." + }, + { + "name": "HOP_USE_NATIVE_FILE_DIALOG", + "value": "N", + "description": "Set this value to Y if you want to use the system file open/save dialog when browsing files" + }, + { + "name": "HOP_AUTO_CREATE_CONFIG", + "value": "Y", + "description": "Set this value to N if you don't want to automatically create a hop configuration file (hop-config.json) when it's missing" + } + ], + "LocaleDefault": "en_BE", + "guiProperties": { + "FontFixedSize": "13", + "MaxUndo": "100", + "DarkMode": "Y", + "FontNoteSize": "13", + "ShowOSLook": "Y", + "FontFixedStyle": "0", + "FontNoteName": ".AppleSystemUIFont", + "FontFixedName": "Monospaced", + "FontGraphStyle": "0", + "FontDefaultSize": "13", + "GraphColorR": "255", + "FontGraphSize": "13", + "IconSize": "32", + "BackgroundColorB": "255", + "FontNoteStyle": "0", + "FontGraphName": ".AppleSystemUIFont", + "FontDefaultName": ".AppleSystemUIFont", + "GraphColorG": "255", + "UseGlobalFileBookmarks": "Y", + "FontDefaultStyle": "0", + "GraphColorB": "255", + "BackgroundColorR": "255", + "BackgroundColorG": "255", + "WorkflowDialogStyle": "RESIZE,MAX,MIN", + "LineWidth": "1", + "ContextDialogShowCategories": "Y" + }, + "projectsConfig": { + "enabled": true, + "projectMandatory": true, + "environmentMandatory": true, + "defaultProject": "default", + "defaultEnvironment": null, + "standardParentProject": "default", + "standardProjectsFolder": null, + "projectConfigurations": [ + { + "projectName": "default", + "projectHome": "${HOP_CONFIG_FOLDER}", + "configFilename": "project-config.json" + } + ], + "lifecycleEnvironments": [ + { + "name": "dev", + "purpose": "Testing", + "projectName": "default", + "configurationFiles": [ + "${PROJECT_HOME}/dev-env-config.json" + ] + } + ], + "projectLifecycles": [] + } +} \ No newline at end of file diff --git a/integration-tests/smb/main-001-create-folder.hwf b/integration-tests/smb/main-001-create-folder.hwf new file mode 100644 index 00000000000..edd640e33c8 --- /dev/null +++ b/integration-tests/smb/main-001-create-folder.hwf @@ -0,0 +1,54 @@ + + + + main-001-create-folder + Y + Create a folder on the share + + + + + Start + SPECIAL + N + 32 + 64 + + + + finance:///roundtrip-create + N + Create roundtrip + CREATE_FOLDER + N + 48 + 64 + + + + + Start + Create roundtrip + Y + Y + Y + + + diff --git a/integration-tests/smb/main-002-write-and-read.hwf b/integration-tests/smb/main-002-write-and-read.hwf new file mode 100644 index 00000000000..f2d6b69987a --- /dev/null +++ b/integration-tests/smb/main-002-write-and-read.hwf @@ -0,0 +1,96 @@ + + + + main-002-write-and-read + Y + Write a line and read it back + + + + + Start + SPECIAL + N + 32 + 64 + + + + ${PROJECT_HOME}/../vfs/write-line.hpl + N + N + N + N + Basic + Y + + + FILE + finance:///roundtrip-write/daily.csv + + Y + + local + write-line + PIPELINE + N + 48 + 64 + + + ${PROJECT_HOME}/../vfs/expect-alpha.hpl + N + N + N + N + Basic + Y + + + FILE + finance:///roundtrip-write/daily.csv + + Y + + local + expect-alpha + PIPELINE + N + 208 + 64 + + + + + Start + write-line + Y + Y + Y + + + write-line + expect-alpha + Y + Y + N + + + diff --git a/integration-tests/smb/main-003-append.hwf b/integration-tests/smb/main-003-append.hwf new file mode 100644 index 00000000000..55633e84b5e --- /dev/null +++ b/integration-tests/smb/main-003-append.hwf @@ -0,0 +1,125 @@ + + + + main-003-append + Y + Append a line and read both back + + + + + Start + SPECIAL + N + 32 + 64 + + + + ${PROJECT_HOME}/../vfs/write-line.hpl + N + N + N + N + Basic + Y + + + FILE + finance:///roundtrip-append/daily.csv + + Y + + local + write-line + PIPELINE + N + 48 + 64 + + + ${PROJECT_HOME}/../vfs/append-line.hpl + N + N + N + N + Basic + Y + + + FILE + finance:///roundtrip-append/daily.csv + + Y + + local + append-line + PIPELINE + N + 208 + 64 + + + ${PROJECT_HOME}/../vfs/expect-alpha-beta.hpl + N + N + N + N + Basic + Y + + + FILE + finance:///roundtrip-append/daily.csv + + Y + + local + expect-alpha-beta + PIPELINE + N + 368 + 64 + + + + + Start + write-line + Y + Y + Y + + + write-line + append-line + Y + Y + N + + + append-line + expect-alpha-beta + Y + Y + N + + + diff --git a/integration-tests/smb/main-004-list.hwf b/integration-tests/smb/main-004-list.hwf new file mode 100644 index 00000000000..275d1851966 --- /dev/null +++ b/integration-tests/smb/main-004-list.hwf @@ -0,0 +1,100 @@ + + + + main-004-list + Y + List the file that was just written + + + + + Start + SPECIAL + N + 32 + 64 + + + + ${PROJECT_HOME}/../vfs/write-line.hpl + N + N + N + N + Basic + Y + + + FILE + finance:///roundtrip-list/daily.csv + + Y + + local + write-line + PIPELINE + N + 48 + 64 + + + ${PROJECT_HOME}/../vfs/list-one.hpl + N + N + N + N + Basic + Y + + + DIR + finance:///roundtrip-list + + + MASK + daily.csv + + Y + + local + list-one + PIPELINE + N + 208 + 64 + + + + + Start + write-line + Y + Y + Y + + + write-line + list-one + Y + Y + N + + + diff --git a/integration-tests/smb/main-005-copy.hwf b/integration-tests/smb/main-005-copy.hwf new file mode 100644 index 00000000000..4c817693b59 --- /dev/null +++ b/integration-tests/smb/main-005-copy.hwf @@ -0,0 +1,125 @@ + + + + main-005-copy + Y + Copy a file on the share and check both names + + + + + Start + SPECIAL + N + 32 + 64 + + + + ${PROJECT_HOME}/../vfs/write-line.hpl + N + N + N + N + Basic + Y + + + FILE + finance:///roundtrip-copy/daily.csv + + Y + + local + write-line + PIPELINE + N + 48 + 64 + + + N + N + Y + N + N + Y + Y + + + finance:///roundtrip-copy/daily.csv + finance:///roundtrip-copy/copy.csv + + + + Copy daily + COPY_FILES + N + 208 + 64 + + + finance:///roundtrip-copy/daily.csv + Original exists + FILE_EXISTS + N + 368 + 64 + + + finance:///roundtrip-copy/copy.csv + Copy exists + FILE_EXISTS + N + 528 + 64 + + + + + Start + write-line + Y + Y + Y + + + write-line + Copy daily + Y + Y + N + + + Copy daily + Original exists + Y + Y + N + + + Original exists + Copy exists + Y + Y + N + + + diff --git a/integration-tests/smb/main-006-move.hwf b/integration-tests/smb/main-006-move.hwf new file mode 100644 index 00000000000..a9529e45c6f --- /dev/null +++ b/integration-tests/smb/main-006-move.hwf @@ -0,0 +1,156 @@ + + + + main-006-move + Y + Move a file and check the old name is gone + + + + + Start + SPECIAL + N + 32 + 64 + + + + ${PROJECT_HOME}/../vfs/write-line.hpl + N + N + N + N + Basic + Y + + + FILE + finance:///roundtrip-move/daily.csv + + Y + + local + write-line + PIPELINE + N + 48 + 64 + + + N + N + N + Y + Y + success_if_no_errors + overwrite_file + overwrite_file + N + + + finance:///roundtrip-move/daily.csv + finance:///roundtrip-move/moved.csv + + + + Move daily + MOVE_FILES + N + 208 + 64 + + + finance:///roundtrip-move/moved.csv + Moved exists + FILE_EXISTS + N + 368 + 64 + + + finance:///roundtrip-move/daily.csv + Old name exists + FILE_EXISTS + N + 528 + 64 + + + Old name was still there + ABORT + Y + N + 688 + 160 + + + Old name is gone + SUCCESS + N + 848 + 160 + + + + + Start + write-line + Y + Y + Y + + + write-line + Move daily + Y + Y + N + + + Move daily + Moved exists + Y + Y + N + + + Moved exists + Old name exists + Y + Y + N + + + Old name exists + Old name was still there + Y + Y + N + + + Old name exists + Old name is gone + Y + N + N + + + diff --git a/integration-tests/smb/main-007-delete-file.hwf b/integration-tests/smb/main-007-delete-file.hwf new file mode 100644 index 00000000000..8c80df248f6 --- /dev/null +++ b/integration-tests/smb/main-007-delete-file.hwf @@ -0,0 +1,133 @@ + + + + main-007-delete-file + Y + Delete a file and check it is gone + + + + + Start + SPECIAL + N + 32 + 64 + + + + ${PROJECT_HOME}/../vfs/write-line.hpl + N + N + N + N + Basic + Y + + + FILE + finance:///roundtrip-delete/daily.csv + + Y + + local + write-line + PIPELINE + N + 48 + 64 + + + N + N + + + finance:///roundtrip-delete + daily.csv + + + Delete daily + DELETE_FILES + N + 208 + 64 + + + finance:///roundtrip-delete/daily.csv + Daily exists + FILE_EXISTS + N + 368 + 64 + + + File was still there + ABORT + Y + N + 528 + 160 + + + File is gone + SUCCESS + N + 688 + 160 + + + + + Start + write-line + Y + Y + Y + + + write-line + Delete daily + Y + Y + N + + + Delete daily + Daily exists + Y + Y + N + + + Daily exists + File was still there + Y + Y + N + + + Daily exists + File is gone + Y + N + N + + + diff --git a/integration-tests/smb/main-008-delete-folder.hwf b/integration-tests/smb/main-008-delete-folder.hwf new file mode 100644 index 00000000000..ca090faa7ab --- /dev/null +++ b/integration-tests/smb/main-008-delete-folder.hwf @@ -0,0 +1,91 @@ + + + + main-008-delete-folder + Y + Delete a folder and create it again + + + + + Start + SPECIAL + N + 32 + 64 + + + + finance:///roundtrip-folder + N + Create folder + CREATE_FOLDER + N + 48 + 64 + + + N + + + finance:///roundtrip-folder + + + success_if_no_errors + Delete folder + DELETE_FOLDERS + N + 208 + 64 + + + finance:///roundtrip-folder + N + Create folder again + CREATE_FOLDER + N + 368 + 64 + + + + + Start + Create folder + Y + Y + Y + + + Create folder + Delete folder + Y + Y + N + + + Delete folder + Create folder again + Y + Y + N + + + diff --git a/integration-tests/smb/main-009-rooted.hwf b/integration-tests/smb/main-009-rooted.hwf new file mode 100644 index 00000000000..ea3ca4ef84c --- /dev/null +++ b/integration-tests/smb/main-009-rooted.hwf @@ -0,0 +1,97 @@ + + + + main-009-rooted + Y + A base folder keeps the file under that folder on the share + + + + + Start + SPECIAL + N + 32 + 64 + + + + ${PROJECT_HOME}/../vfs/write-line.hpl + N + N + N + N + Basic + Y + + + FILE + finance-rooted:///note.csv + + Y + + local + write-line + PIPELINE + N + 48 + 64 + + + finance-rooted:///note.csv + Visible inside the root + FILE_EXISTS + N + 208 + 64 + + + finance:///restricted/note.csv + Visible on the share + FILE_EXISTS + N + 368 + 64 + + + + + Start + write-line + Y + Y + Y + + + write-line + Visible inside the root + Y + Y + N + + + Visible inside the root + Visible on the share + Y + Y + N + + + diff --git a/integration-tests/smb/main-010-smb3-encryption.hwf b/integration-tests/smb/main-010-smb3-encryption.hwf new file mode 100644 index 00000000000..6e8f59c0653 --- /dev/null +++ b/integration-tests/smb/main-010-smb3-encryption.hwf @@ -0,0 +1,96 @@ + + + + main-010-smb3-encryption + Y + Write and read through the server that requires SMB3 encryption + + + + + Start + SPECIAL + N + 32 + 64 + + + + ${PROJECT_HOME}/../vfs/write-line.hpl + N + N + N + N + Basic + Y + + + FILE + finance-encrypted:///roundtrip-encrypt/secret.csv + + Y + + local + write-line + PIPELINE + N + 48 + 64 + + + ${PROJECT_HOME}/../vfs/expect-alpha.hpl + N + N + N + N + Basic + Y + + + FILE + finance-encrypted:///roundtrip-encrypt/secret.csv + + Y + + local + expect-alpha + PIPELINE + N + 208 + 64 + + + + + Start + write-line + Y + Y + Y + + + write-line + expect-alpha + Y + Y + N + + + diff --git a/integration-tests/smb/main-011-bad-password.hwf b/integration-tests/smb/main-011-bad-password.hwf new file mode 100644 index 00000000000..433f4faac15 --- /dev/null +++ b/integration-tests/smb/main-011-bad-password.hwf @@ -0,0 +1,111 @@ + + + + main-011-bad-password + Y + A wrong password does not create a file + + + + + Start + SPECIAL + N + 32 + 64 + + + + ${PROJECT_HOME}/../vfs/write-line.hpl + N + N + N + N + Basic + Y + + + FILE + finance-bad:///should-not-exist.csv + + Y + + local + write-line + PIPELINE + N + 48 + 64 + + + finance:///should-not-exist.csv + File was created + FILE_EXISTS + N + 208 + 64 + + + Wrong password created a file + ABORT + Y + N + 368 + 160 + + + File was not created + SUCCESS + N + 528 + 160 + + + + + Start + write-line + Y + Y + Y + + + write-line + File was created + Y + N + N + + + File was created + Wrong password created a file + Y + Y + N + + + File was created + File was not created + Y + N + N + + + diff --git a/integration-tests/smb/main-012-missing-share.hwf b/integration-tests/smb/main-012-missing-share.hwf new file mode 100644 index 00000000000..732443475d2 --- /dev/null +++ b/integration-tests/smb/main-012-missing-share.hwf @@ -0,0 +1,96 @@ + + + + main-012-missing-share + Y + A missing share fails the write + + + + + Start + SPECIAL + N + 32 + 64 + + + + ${PROJECT_HOME}/../vfs/write-line.hpl + N + N + N + N + Basic + Y + + + FILE + finance-missing:///x.csv + + Y + + local + write-line + PIPELINE + N + 48 + 64 + + + Missing share accepted the write + ABORT + Y + N + 208 + 160 + + + Write failed + SUCCESS + N + 368 + 160 + + + + + Start + write-line + Y + Y + Y + + + write-line + Missing share accepted the write + Y + Y + N + + + write-line + Write failed + Y + N + N + + + diff --git a/integration-tests/smb/main-013-two-copies.hwf b/integration-tests/smb/main-013-two-copies.hwf new file mode 100644 index 00000000000..a65674afb1c --- /dev/null +++ b/integration-tests/smb/main-013-two-copies.hwf @@ -0,0 +1,97 @@ + + + + main-013-two-copies + Y + Two outputs write two files in one pipeline + + + + + Start + SPECIAL + N + 32 + 64 + + + + ${PROJECT_HOME}/../vfs/write-two.hpl + N + N + N + N + Basic + Y + + + DIR + finance:///roundtrip-two + + Y + + local + write-two + PIPELINE + N + 48 + 64 + + + finance:///roundtrip-two/a.csv + a.csv exists + FILE_EXISTS + N + 208 + 64 + + + finance:///roundtrip-two/b.csv + b.csv exists + FILE_EXISTS + N + 368 + 64 + + + + + Start + write-two + Y + Y + Y + + + write-two + a.csv exists + Y + Y + N + + + a.csv exists + b.csv exists + Y + Y + N + + + diff --git a/integration-tests/smb/metadata/pipeline-run-configuration/local.json b/integration-tests/smb/metadata/pipeline-run-configuration/local.json new file mode 100644 index 00000000000..0c0d87c84d2 --- /dev/null +++ b/integration-tests/smb/metadata/pipeline-run-configuration/local.json @@ -0,0 +1,22 @@ +{ + "engineRunConfiguration": { + "Local": { + "feedback_size": "50000", + "sample_size": "100", + "sample_type_in_gui": "Last", + "wait_time": "20", + "rowset_size": "10000", + "safe_mode": false, + "show_feedback": false, + "topo_sort": false, + "gather_metrics": false, + "transactional": false + } + }, + "defaultSelection": true, + "configurationVariables": [], + "name": "local", + "description": "", + "dataProfile": "", + "executionInfoLocationName": "" +} \ No newline at end of file diff --git a/integration-tests/smb/metadata/smb-connection/finance-bad.json b/integration-tests/smb/metadata/smb-connection/finance-bad.json new file mode 100644 index 00000000000..4f540c72578 --- /dev/null +++ b/integration-tests/smb/metadata/smb-connection/finance-bad.json @@ -0,0 +1,18 @@ +{ + "name": "finance-bad", + "description": "SMB integration test", + "hostname": "${SMB_HOSTNAME}", + "port": "${SMB_PORT}", + "share": "${SMB_SHARE}", + "basePath": "", + "authType": "NTLM", + "domain": "", + "username": "${SMB_USER}", + "password": "${SMB_BAD_PASSWORD}", + "minimumDialect": "SMB_2_0_2", + "requireSigning": false, + "encryptData": false, + "dfsEnabled": false, + "callTimeoutSeconds": "60", + "socketTimeoutSeconds": "60" +} diff --git a/integration-tests/smb/metadata/smb-connection/finance-encrypted.json b/integration-tests/smb/metadata/smb-connection/finance-encrypted.json new file mode 100644 index 00000000000..2be4140e499 --- /dev/null +++ b/integration-tests/smb/metadata/smb-connection/finance-encrypted.json @@ -0,0 +1,18 @@ +{ + "name": "finance-encrypted", + "description": "SMB integration test", + "hostname": "${SMB_ENCRYPTED_HOSTNAME}", + "port": "${SMB_PORT}", + "share": "${SMB_SHARE}", + "basePath": "", + "authType": "NTLM", + "domain": "", + "username": "${SMB_USER}", + "password": "${SMB_PASSWORD}", + "minimumDialect": "SMB_2_0_2", + "requireSigning": false, + "encryptData": true, + "dfsEnabled": false, + "callTimeoutSeconds": "60", + "socketTimeoutSeconds": "60" +} diff --git a/integration-tests/smb/metadata/smb-connection/finance-missing.json b/integration-tests/smb/metadata/smb-connection/finance-missing.json new file mode 100644 index 00000000000..f1b6727a64d --- /dev/null +++ b/integration-tests/smb/metadata/smb-connection/finance-missing.json @@ -0,0 +1,18 @@ +{ + "name": "finance-missing", + "description": "SMB integration test", + "hostname": "${SMB_HOSTNAME}", + "port": "${SMB_PORT}", + "share": "missing-share", + "basePath": "", + "authType": "NTLM", + "domain": "", + "username": "${SMB_USER}", + "password": "${SMB_PASSWORD}", + "minimumDialect": "SMB_2_0_2", + "requireSigning": false, + "encryptData": false, + "dfsEnabled": false, + "callTimeoutSeconds": "60", + "socketTimeoutSeconds": "60" +} diff --git a/integration-tests/smb/metadata/smb-connection/finance-rooted.json b/integration-tests/smb/metadata/smb-connection/finance-rooted.json new file mode 100644 index 00000000000..125a29d21ba --- /dev/null +++ b/integration-tests/smb/metadata/smb-connection/finance-rooted.json @@ -0,0 +1,18 @@ +{ + "name": "finance-rooted", + "description": "SMB integration test", + "hostname": "${SMB_HOSTNAME}", + "port": "${SMB_PORT}", + "share": "${SMB_SHARE}", + "basePath": "restricted", + "authType": "NTLM", + "domain": "", + "username": "${SMB_USER}", + "password": "${SMB_PASSWORD}", + "minimumDialect": "SMB_2_0_2", + "requireSigning": false, + "encryptData": false, + "dfsEnabled": false, + "callTimeoutSeconds": "60", + "socketTimeoutSeconds": "60" +} diff --git a/integration-tests/smb/metadata/smb-connection/finance.json b/integration-tests/smb/metadata/smb-connection/finance.json new file mode 100644 index 00000000000..301a1d7d0e1 --- /dev/null +++ b/integration-tests/smb/metadata/smb-connection/finance.json @@ -0,0 +1,18 @@ +{ + "name": "finance", + "description": "SMB integration test", + "hostname": "${SMB_HOSTNAME}", + "port": "${SMB_PORT}", + "share": "${SMB_SHARE}", + "basePath": "", + "authType": "NTLM", + "domain": "", + "username": "${SMB_USER}", + "password": "${SMB_PASSWORD}", + "minimumDialect": "SMB_2_0_2", + "requireSigning": false, + "encryptData": false, + "dfsEnabled": false, + "callTimeoutSeconds": "60", + "socketTimeoutSeconds": "60" +} diff --git a/integration-tests/smb/metadata/workflow-run-configuration/local.json b/integration-tests/smb/metadata/workflow-run-configuration/local.json new file mode 100644 index 00000000000..1d0cf74baec --- /dev/null +++ b/integration-tests/smb/metadata/workflow-run-configuration/local.json @@ -0,0 +1,11 @@ +{ + "engineRunConfiguration": { + "Local": { + "safe_mode": false, + "transactional": false + } + }, + "defaultSelection": true, + "name": "local", + "description": "Runs your workflows locally with the standard local Hop workflow engine" +} \ No newline at end of file diff --git a/integration-tests/smb/project-config.json b/integration-tests/smb/project-config.json new file mode 100644 index 00000000000..8f2895c0bb5 --- /dev/null +++ b/integration-tests/smb/project-config.json @@ -0,0 +1,13 @@ +{ + "metadataBaseFolder" : "${PROJECT_HOME}/metadata", + "unitTestsBasePath" : "${PROJECT_HOME}", + "dataSetsCsvFolder" : "${PROJECT_HOME}/datasets", + "enforcingExecutionInHome" : false, + "config" : { + "variables" : [ { + "name" : "HOP_LICENSE_HEADER_FILE", + "value" : "${PROJECT_HOME}/../asf-header.txt", + "description" : "Shared VFS pipelines live in ../vfs, so this project does not force every executable to sit under its own home." + } ] + } +} diff --git a/integration-tests/vfs/append-line.hpl b/integration-tests/vfs/append-line.hpl new file mode 100644 index 00000000000..d8933c22ab9 --- /dev/null +++ b/integration-tests/vfs/append-line.hpl @@ -0,0 +1,116 @@ + + + + + append-line + Y + Append the line beta to ${FILE} + Normal + + + FILE + + Full VFS filename + + + + + + Line + Append + Y + + + + + Line + DataGrid + Y + 1 + + none + + + + + beta + + + + + line + String + -1 + -1 + N + + + + 80 + 80 + + + + Append + TextFileOutput + Y + 1 + + none + + + , + +

N
+
N
+ UNIX + None + UTF-8 + N + Y + + ${FILE} + N + N + + Y + N + N + N + N + N + N + N + + + + line + String + none + -1 + -1 + + + + 280 + 80 + + + diff --git a/integration-tests/vfs/disabled.txt b/integration-tests/vfs/disabled.txt new file mode 100644 index 00000000000..91372be14a0 --- /dev/null +++ b/integration-tests/vfs/disabled.txt @@ -0,0 +1 @@ +Shared VFS pipelines. Not a standalone project. Called from integration-tests/smb. diff --git a/integration-tests/vfs/expect-alpha-beta.hpl b/integration-tests/vfs/expect-alpha-beta.hpl new file mode 100644 index 00000000000..40bf9f9bba2 --- /dev/null +++ b/integration-tests/vfs/expect-alpha-beta.hpl @@ -0,0 +1,272 @@ + + + + + expect-alpha-beta + Y + Fail unless ${FILE} contains alpha and beta + Normal + + + FILE + + Full VFS filename + + + + + + Read text + Count rows + Y + + + Count rows + Wrong count? + Y + + + Wrong count? + Abort + Y + + + Wrong count? + Wrong lowest? + Y + + + Wrong lowest? + Abort + Y + + + Wrong lowest? + Wrong highest? + Y + + + Wrong highest? + Abort + Y + + + + + Read text + TextFileInput2 + N + 1 + + none + + + N + N + , + +
N
+ 1 +
N
+ Y + UNIX + UTF-8 + Characters + Y + en_US + + ${FILE} + + + Y + N + CSV + None + + + + line + String + none + -1 + -1 + N + + + 0 + N + + 80 + 80 + +
+ + Count rows + MemoryGroupBy + Y + 1 + + none + + + Y + + + + rows_read + line + COUNT_ANY + + + + lowest + line + MIN + + + + highest + line + MAX + + + + + 400 + 160 + + + + Wrong count? + FilterRows + Y + 1 + + none + + + Abort + Wrong lowest? + + + N + rows_read + <> + + + constant + Integer + 2 + -1 + 0 + N + ####0;-####0 + + + + + 560 + 80 + + + + Wrong lowest? + FilterRows + Y + 1 + + none + + + Abort + Wrong highest? + + + N + lowest + <> + + + constant + String + alpha + -1 + -1 + N + + + + + + 720 + 80 + + + + Wrong highest? + FilterRows + Y + 1 + + none + + + Abort + + + + N + highest + <> + + + constant + String + beta + -1 + -1 + N + + + + + + 880 + 80 + + + + Abort + Abort + Y + 1 + + none + + + ABORT_WITH_ERROR + Y + 0 + + 1040 + 160 + + +
diff --git a/integration-tests/vfs/expect-alpha.hpl b/integration-tests/vfs/expect-alpha.hpl new file mode 100644 index 00000000000..e442847f979 --- /dev/null +++ b/integration-tests/vfs/expect-alpha.hpl @@ -0,0 +1,272 @@ + + + + + expect-alpha + Y + Fail unless ${FILE} is the single line alpha + Normal + + + FILE + + Full VFS filename + + + + + + Read text + Count rows + Y + + + Count rows + Wrong count? + Y + + + Wrong count? + Abort + Y + + + Wrong count? + Wrong lowest? + Y + + + Wrong lowest? + Abort + Y + + + Wrong lowest? + Wrong highest? + Y + + + Wrong highest? + Abort + Y + + + + + Read text + TextFileInput2 + N + 1 + + none + + + N + N + , + +
N
+ 1 +
N
+ Y + UNIX + UTF-8 + Characters + Y + en_US + + ${FILE} + + + Y + N + CSV + None + + + + line + String + none + -1 + -1 + N + + + 0 + N + + 80 + 80 + +
+ + Count rows + MemoryGroupBy + Y + 1 + + none + + + Y + + + + rows_read + line + COUNT_ANY + + + + lowest + line + MIN + + + + highest + line + MAX + + + + + 400 + 160 + + + + Wrong count? + FilterRows + Y + 1 + + none + + + Abort + Wrong lowest? + + + N + rows_read + <> + + + constant + Integer + 1 + -1 + 0 + N + ####0;-####0 + + + + + 560 + 80 + + + + Wrong lowest? + FilterRows + Y + 1 + + none + + + Abort + Wrong highest? + + + N + lowest + <> + + + constant + String + alpha + -1 + -1 + N + + + + + + 720 + 80 + + + + Wrong highest? + FilterRows + Y + 1 + + none + + + Abort + + + + N + highest + <> + + + constant + String + alpha + -1 + -1 + N + + + + + + 880 + 80 + + + + Abort + Abort + Y + 1 + + none + + + ABORT_WITH_ERROR + Y + 0 + + 1040 + 160 + + +
diff --git a/integration-tests/vfs/list-one.hpl b/integration-tests/vfs/list-one.hpl new file mode 100644 index 00000000000..000dbd63e93 --- /dev/null +++ b/integration-tests/vfs/list-one.hpl @@ -0,0 +1,174 @@ + + + + + list-one + Y + Fail unless ${DIR} contains exactly one file matching ${MASK} + Normal + + + DIR + + Folder URI + + + MASK + + File mask + + + + + + Get file names + Count rows + Y + + + Count rows + Wrong count? + Y + + + Wrong count? + Abort + Y + + + + + Get file names + GetFileNames + Y + 1 + + none + + + + ${DIR} + ${MASK} + + Y + N + + + only_files + + N + N + N + 0 + N + Y + + 80 + 80 + + + + + Count rows + MemoryGroupBy + Y + 1 + + none + + + Y + + + + rows_read + short_filename + COUNT_ANY + + + + lowest + short_filename + MIN + + + + highest + short_filename + MAX + + + + + 400 + 160 + + + + Wrong count? + FilterRows + Y + 1 + + none + + + Abort + + + + N + rows_read + <> + + + constant + Integer + 1 + -1 + 0 + N + ####0;-####0 + + + + + 560 + 80 + + + + Abort + Abort + Y + 1 + + none + + + ABORT_WITH_ERROR + Y + 0 + + 720 + 160 + + + diff --git a/integration-tests/vfs/write-line.hpl b/integration-tests/vfs/write-line.hpl new file mode 100644 index 00000000000..4d9ab759e34 --- /dev/null +++ b/integration-tests/vfs/write-line.hpl @@ -0,0 +1,116 @@ + + + + + write-line + Y + Write the line alpha to ${FILE}, replacing the file + Normal + + + FILE + + Full VFS filename + + + + + + Line + Write + Y + + + + + Line + DataGrid + Y + 1 + + none + + + + + alpha + + + + + line + String + -1 + -1 + N + + + + 80 + 80 + + + + Write + TextFileOutput + Y + 1 + + none + + + , + +
N
+
N
+ UNIX + None + UTF-8 + N + Y + + ${FILE} + N + N + + N + N + N + N + N + N + N + N + + + + line + String + none + -1 + -1 + + + + 280 + 80 + +
+
diff --git a/integration-tests/vfs/write-two.hpl b/integration-tests/vfs/write-two.hpl new file mode 100644 index 00000000000..ee29ec29336 --- /dev/null +++ b/integration-tests/vfs/write-two.hpl @@ -0,0 +1,167 @@ + + + + + write-two + Y + Write two files from one source. The two outputs run side by side. + Normal + + + DIR + + Folder URI + + + + + + Line + Write a + Y + + + Line + Write b + Y + + + + + Line + DataGrid + N + 1 + + none + + + + + alpha + + + + + line + String + -1 + -1 + N + + + + 80 + 80 + + + + Write a + TextFileOutput + Y + 1 + + none + + + , + +
N
+
N
+ UNIX + None + UTF-8 + N + Y + + ${DIR}/a.csv + N + N + + N + N + N + N + N + N + N + N + + + + line + String + none + -1 + -1 + + + + 280 + 48 + +
+ + Write b + TextFileOutput + Y + 1 + + none + + + , + +
N
+
N
+ UNIX + None + UTF-8 + N + Y + + ${DIR}/b.csv + N + N + + N + N + N + N + N + N + N + N + + + + line + String + none + -1 + -1 + + + + 280 + 144 + +
+
diff --git a/plugins/tech/smb/pom.xml b/plugins/tech/smb/pom.xml index 3e29c0c68f6..50f74aed6b9 100644 --- a/plugins/tech/smb/pom.xml +++ b/plugins/tech/smb/pom.xml @@ -32,7 +32,6 @@ org.bouncycastle:bcprov-jdk18on - 1.21.4 @@ -69,36 +68,5 @@ ${junit.version} test
- - org.testcontainers - junit-jupiter - ${testcontainers.version} - test - - - org.testcontainers - testcontainers - ${testcontainers.version} - test - - - - - - - org.apache.maven.plugins - maven-failsafe-plugin - - - - integration-test - verify - - - - - - diff --git a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbConnectionTester.java b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbConnectionTester.java index bf5804f4538..cde9b2db398 100644 --- a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbConnectionTester.java +++ b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbConnectionTester.java @@ -42,7 +42,7 @@ public static String test(IVariables variables, SmbConnection connection) throws domain, username, context)) { - share.children(""); + share.children(SmbPaths.sharePath(settings.basePath(), "")); } return BaseMessages.getString( PKG, "Smb.Test.Ok", settings.host(), Integer.toString(settings.port()), settings.share()); diff --git a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbErrors.java b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbErrors.java index 49f82eeb111..8567b2403f0 100644 --- a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbErrors.java +++ b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbErrors.java @@ -43,6 +43,12 @@ static boolean notFound(Throwable error) { } static IOException io(Throwable error) { + if (notFound(error)) { + if (error instanceof SmbNotFoundException notFound) { + return notFound; + } + return new SmbNotFoundException(error.getMessage(), error); + } if (error instanceof IOException ioException) { return ioException; } @@ -55,8 +61,14 @@ static IOException io(Throwable error) { /** The path is not on the share. Distinct from access denied. */ static final class SmbNotFoundException extends IOException { + private static final long serialVersionUID = 1L; + SmbNotFoundException(String sharePath) { super(sharePath == null || sharePath.isEmpty() ? "SMB path not found" : sharePath); } + + SmbNotFoundException(String sharePath, Throwable cause) { + super(sharePath == null || sharePath.isEmpty() ? "SMB path not found" : sharePath, cause); + } } } diff --git a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbFileNameParser.java b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbFileNameParser.java index d9ac72c29de..48721cc9cf6 100644 --- a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbFileNameParser.java +++ b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbFileNameParser.java @@ -40,6 +40,15 @@ public FileName parseUri(VfsComponentContext context, FileName base, String uri) throws FileSystemException { StringBuilder name = new StringBuilder(); String scheme = UriParser.extractScheme(uri, name); + if (scheme == null) { + if (base == null) { + throw new FileSystemException("vfs.provider/absolute-uri.error", uri); + } + scheme = base.getScheme(); + if (name.isEmpty() || name.charAt(0) != '/') { + name.insert(0, base.getPath().endsWith("/") ? base.getPath() : base.getPath() + "/"); + } + } UriParser.canonicalizePath(name, 0, name.length(), this); UriParser.fixSeparators(name); while (name.length() > 0 && name.charAt(0) == '/') { diff --git a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbFileObject.java b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbFileObject.java index 714699b6a1b..70ad68f1a28 100644 --- a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbFileObject.java +++ b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbFileObject.java @@ -91,18 +91,6 @@ protected InputStream doGetInputStream() throws Exception { return fs().share().openRead(sharePath()); } - /** - * HopVfs.getOutputStream() calls createFile() and then getOutputStream(). The default createFile - * opens and closes an output stream, which would create an empty file and then open a second one. - * Mark the type only. The bytes go out on the stream. - */ - @Override - public void createFile() throws FileSystemException { - if (!exists()) { - injectType(FileType.FILE); - } - } - @Override protected OutputStream doGetOutputStream(boolean append) throws Exception { return fs().share().openWrite(sharePath(), append); @@ -123,6 +111,9 @@ protected void doCreateFolder() throws Exception { @Override protected void doDelete() throws Exception { + if (getName().getPath().equals("/")) { + throw new FileSystemException("vfs.provider/delete-root.error", getName()); + } fs().share().delete(sharePath()); doDetach(); } diff --git a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbRandomAccessContent.java b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbRandomAccessContent.java index 9bf4b0fad75..d5acc335e57 100644 --- a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbRandomAccessContent.java +++ b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbRandomAccessContent.java @@ -16,7 +16,6 @@ */ package org.apache.hop.vfs.smb; -import java.io.ByteArrayInputStream; import java.io.DataInputStream; import java.io.IOException; import java.io.InputStream; @@ -101,25 +100,6 @@ public void write(byte[] b, int off, int len) throws IOException { filePointer += len; } - @Override - public InputStream getInputStream() throws IOException { - // The inherited stream tracks the file pointer. A detached copy would not. - long size = Math.max(0, length() - filePointer); - if (size > Integer.MAX_VALUE) { - return super.getInputStream(); - } - byte[] data = new byte[(int) size]; - int offset = 0; - while (offset < data.length) { - int read = random.read(data, offset, data.length - offset, filePointer + offset); - if (read < 0) { - break; - } - offset += read; - } - return new ByteArrayInputStream(data, 0, offset); - } - private final class OffsetInputStream extends InputStream { @Override public int read() throws IOException { diff --git a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbjSmbShare.java b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbjSmbShare.java index 53c36c92855..106beb8692a 100644 --- a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbjSmbShare.java +++ b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbjSmbShare.java @@ -167,6 +167,16 @@ public OutputStream openWrite(String sharePath, boolean append) throws IOExcepti FILE_OPTIONS); // true starts the stream at the current end of the file. return new FilterOutputStream(file.getOutputStream(append)) { + @Override + public void write(byte[] b, int off, int len) throws IOException { + out.write(b, off, len); + } + + @Override + public void write(byte[] b) throws IOException { + out.write(b, 0, b.length); + } + @Override public void close() throws IOException { try { @@ -223,6 +233,7 @@ public void rename(String sharePath, String newSharePath) throws IOException { if (entry == null) { throw new SmbErrors.SmbNotFoundException(sharePath); } + mkdirs(parent(newSharePath)); try { if (entry.directory()) { try (Directory directory = diff --git a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/metadata/SmbConnection.java b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/metadata/SmbConnection.java index e729cf18004..b0ced5c228e 100644 --- a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/metadata/SmbConnection.java +++ b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/metadata/SmbConnection.java @@ -57,10 +57,13 @@ category = HopMetadataCategory.FILE_STORAGE, documentationUrl = "/metadata-types/smb-connection.html", hopMetadataPropertyType = HopMetadataPropertyType.VFS_SMB_CONNECTION, + supportsGlobalReplace = true, classLoaderGroup = "vfs-smb") public class SmbConnection extends HopMetadataBase implements Serializable, IHopMetadata, IVfsBrowseLocation, IGuiPluginCompositeWidgetsListener { + private static final long serialVersionUID = 1L; + private static final Class PKG = SmbConnection.class; public static final String GROUP_SERVER = "Server"; diff --git a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/metadata/SmbConnectionEditor.java b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/metadata/SmbConnectionEditor.java index 607b6b5b318..efc3e3480f5 100644 --- a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/metadata/SmbConnectionEditor.java +++ b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/metadata/SmbConnectionEditor.java @@ -151,7 +151,7 @@ public void setWidgetsContent() { @Override public void getWidgetsContent(SmbConnection meta) { meta.setName(wName.getText()); - guiCompositeWidgets.getWidgetsContents(metadata, GUI_PLUGIN_ELEMENT_PARENT_ID); + guiCompositeWidgets.getWidgetsContents(meta, GUI_PLUGIN_ELEMENT_PARENT_ID); } @Override diff --git a/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/MemorySmbShare.java b/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/MemorySmbShare.java index bc708e3c56a..6a6f0045d91 100644 --- a/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/MemorySmbShare.java +++ b/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/MemorySmbShare.java @@ -222,6 +222,7 @@ public void rename(String sharePath, String newSharePath) throws IOException { if (node == null || node.denied) { throw new SmbErrors.SmbNotFoundException(sharePath); } + mkdirs(parent(newSharePath)); nodes.remove(sharePath); nodes.put(newSharePath, node); String prefix = sharePath + "\\"; diff --git a/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbFileObjectTest.java b/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbFileObjectTest.java index 0242ac9fb90..b4b0cd2c2b5 100644 --- a/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbFileObjectTest.java +++ b/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbFileObjectTest.java @@ -46,7 +46,8 @@ void readsWritesListsRenamesAndDeletes() throws Exception { folder.createFolder(); FileObject file = manager.resolveFile("finance:///reports/daily.csv"); file.createFile(); - assertEquals(null, share.bytes("reports\\daily.csv")); + assertArrayEquals(new byte[0], share.bytes("reports\\daily.csv")); + assertTrue(file.exists()); write(file, "alpha"); assertArrayEquals(bytes("alpha"), share.bytes("reports\\daily.csv")); try (InputStream in = file.getContent().getInputStream()) { @@ -72,12 +73,18 @@ void readsWritesListsRenamesAndDeletes() throws Exception { assertEquals(1, children.length); assertTrue(children[0].getName().getBaseName().equals("daily.csv")); - FileObject renamed = manager.resolveFile("finance:///reports/moved.csv"); + FileObject relative = folder.resolveFile("relative.csv"); + assertEquals("finance:///reports/relative.csv", relative.getName().getURI()); + + FileObject renamed = manager.resolveFile("finance:///reports/archive/moved.csv"); file.moveTo(renamed); assertFalse(manager.resolveFile("finance:///reports/daily.csv").exists()); assertTrue(renamed.exists()); renamed.delete(); assertFalse(renamed.exists()); + + assertFalse(manager.resolveFile("finance:///").delete()); + assertThrows(FileSystemException.class, () -> manager.resolveFile("finance:///").deleteAll()); } } diff --git a/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbSupportTest.java b/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbSupportTest.java index 46a83f99d20..71cbaa19025 100644 --- a/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbSupportTest.java +++ b/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbSupportTest.java @@ -60,6 +60,31 @@ void parsesNamedConnectionUri() throws Exception { assertEquals("finance:///", name.getRootURI()); } + @Test + void parsesRelativeUriWithBase() throws Exception { + SmbFileName base = + (SmbFileName) + SmbFileNameParser.getInstance().parseUri(null, null, "finance:///reports/2026"); + SmbFileName child = + (SmbFileName) SmbFileNameParser.getInstance().parseUri(null, base, "daily.csv"); + assertEquals("finance", child.getScheme()); + assertEquals("/reports/2026/daily.csv", child.getPath()); + + SmbFileName sibling = + (SmbFileName) SmbFileNameParser.getInstance().parseUri(null, base, "../annual.csv"); + assertEquals("finance", sibling.getScheme()); + assertEquals("/reports/annual.csv", sibling.getPath()); + + SmbFileName absolute = + (SmbFileName) SmbFileNameParser.getInstance().parseUri(null, base, "/direct.csv"); + assertEquals("finance", absolute.getScheme()); + assertEquals("/direct.csv", absolute.getPath()); + + assertThrows( + Exception.class, + () -> SmbFileNameParser.getInstance().parseUri(null, null, "relative.csv")); + } + @Test void ntlmIdentity() { assertEquals(new SmbIdentity("", "alice"), SmbIdentity.parse("", "alice")); diff --git a/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbVfsContainerIT.java b/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbVfsContainerIT.java deleted file mode 100644 index 7ffc9c38988..00000000000 --- a/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbVfsContainerIT.java +++ /dev/null @@ -1,252 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.apache.hop.vfs.smb; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.InputStream; -import java.io.OutputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Path; -import java.time.Duration; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import org.apache.commons.vfs2.FileObject; -import org.apache.commons.vfs2.FileSystemException; -import org.apache.commons.vfs2.RandomAccessContent; -import org.apache.commons.vfs2.impl.DefaultFileSystemManager; -import org.apache.commons.vfs2.util.RandomAccessMode; -import org.apache.hop.core.encryption.Encr; -import org.apache.hop.core.exception.HopException; -import org.apache.hop.core.logging.HopLogStore; -import org.apache.hop.core.variables.Variables; -import org.apache.hop.vfs.smb.metadata.SmbConnection; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.wait.strategy.Wait; -import org.testcontainers.images.builder.ImageFromDockerfile; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; - -/** - * Samba with SMB1 disabled, NTLMv2 only, and mandatory signing. A second container requires SMB3 - * encryption. - */ -@Testcontainers(disabledWithoutDocker = true) -class SmbVfsContainerIT { - - private static final String PASSWORD = "hop-it-password"; - private static final Path DOCKER = Path.of("src/test/docker").toAbsolutePath(); - - private static final ImageFromDockerfile IMAGE = - new ImageFromDockerfile("hop-smb-it", false) - .withDockerfilePath("Dockerfile") - .withFileFromPath("Dockerfile", DOCKER.resolve("Dockerfile")) - .withFileFromPath("smb.conf", DOCKER.resolve("smb.conf")) - .withFileFromPath("entrypoint.sh", DOCKER.resolve("entrypoint.sh")); - - @Container static final GenericContainer samba = container(false); - - @Container static final GenericContainer encrypted = container(true); - - @BeforeAll - static void initPasswords() throws HopException { - HopLogStore.init(); - Encr.init("Hop"); - } - - @Test - void readsAndWritesThroughTheNamedConnection() throws Exception { - try (Opened manager = - opened( - connection("rooted", samba, "restricted"), "share", connection("share", samba, ""))) { - FileObject folder = manager.resolveFile("rooted:///reports"); - folder.createFolder(); - FileObject file = manager.resolveFile("rooted:///reports/daily.csv"); - write(file, "alpha"); - try (InputStream in = file.getContent().getInputStream()) { - assertEquals("alpha", new String(in.readAllBytes(), StandardCharsets.UTF_8)); - } - file.refresh(); - assertTrue(file.getContent().getLastModifiedTime() > 0); - - write(file, "beta"); - try (OutputStream out = file.getContent().getOutputStream(true)) { - out.write("!".getBytes(StandardCharsets.UTF_8)); - } - try (InputStream in = - manager.resolveFile("rooted:///reports/daily.csv").getContent().getInputStream()) { - assertEquals("beta!", new String(in.readAllBytes(), StandardCharsets.UTF_8)); - } - try (RandomAccessContent content = - file.getContent().getRandomAccessContent(RandomAccessMode.READ)) { - content.seek(1); - assertEquals('e', content.readByte()); - } - - assertEquals(1, folder.getChildren().length); - FileObject renamed = manager.resolveFile("rooted:///reports/moved.csv"); - file.moveTo(renamed); - assertFalse(manager.resolveFile("rooted:///reports/daily.csv").exists()); - assertTrue(renamed.exists()); - - assertFalse(manager.resolveFile("share:///reports/moved.csv").exists()); - assertTrue(manager.resolveFile("share:///restricted/reports/moved.csv").exists()); - assertFalse(manager.resolveFile("share:///ok-at-root.txt").exists()); - - assertThrows( - FileSystemException.class, () -> manager.resolveFile("rooted:///../../climbed.txt")); - assertFalse(manager.resolveFile("share:///climbed.txt").exists()); - - renamed.delete(); - assertFalse(renamed.exists()); - } - } - - @Test - void twoThreadsShareOneSession() throws Exception { - try (Opened manager = opened(connection("finance", samba, ""), null, null)) { - FileObject first = manager.resolveFile("finance:///one.txt"); - write(first, "one"); - ExecutorService pool = Executors.newFixedThreadPool(2); - CountDownLatch start = new CountDownLatch(1); - try (InputStream open = first.getContent().getInputStream()) { - Future reader = - pool.submit( - () -> { - start.await(); - return open.readAllBytes(); - }); - Future writer = - pool.submit( - () -> { - start.await(); - write(manager.resolveFile("finance:///two.txt"), "two"); - return null; - }); - start.countDown(); - assertEquals("one", new String((byte[]) reader.get(), StandardCharsets.UTF_8)); - writer.get(); - } finally { - pool.shutdownNow(); - } - try (InputStream in = - manager.resolveFile("finance:///two.txt").getContent().getInputStream()) { - assertEquals("two", new String(in.readAllBytes(), StandardCharsets.UTF_8)); - } - } - } - - @Test - void wrongPasswordDoesNotCreateAFile() throws Exception { - SmbConnection connection = connection("finance", samba, ""); - connection.setPassword("not-the-password"); - try (Opened manager = opened(connection, null, null)) { - FileObject file = manager.resolveFile("finance:///denied.txt"); - FileSystemException error = - assertThrows(FileSystemException.class, () -> write(file, "nope")); - assertFalse(error.getMessage().contains(PASSWORD)); - assertFalse(String.valueOf(error.getCause()).contains(PASSWORD)); - } - } - - @Test - void encryptedShareRoundTrips() throws Exception { - SmbConnection connection = connection("secure", encrypted, ""); - connection.setEncryptData(true); - try (Opened manager = opened(connection, null, null)) { - FileObject file = manager.resolveFile("secure:///secret.txt"); - write(file, "cipher"); - try (InputStream in = file.getContent().getInputStream()) { - assertEquals("cipher", new String(in.readAllBytes(), StandardCharsets.UTF_8)); - } - } - } - - private static GenericContainer container(boolean encrypt) { - GenericContainer container = - new GenericContainer<>(IMAGE) - .withExposedPorts(445) - .withEnv("SMB_PASSWORD", PASSWORD) - .waitingFor(Wait.forListeningPort().withStartupTimeout(Duration.ofMinutes(3))); - if (encrypt) { - container.withEnv("SMB_ENCRYPT", "required"); - } - return container; - } - - private static SmbConnection connection( - String name, GenericContainer server, String basePath) { - SmbConnection connection = new SmbConnection(); - connection.setName(name); - connection.setHostname(server.getHost()); - connection.setPort(Integer.toString(server.getMappedPort(445))); - connection.setShare("data"); - connection.setBasePath(basePath); - connection.setAuthType(SmbAuthType.NTLM); - connection.setUsername("hop"); - connection.setPassword("${SMB_PASSWORD}"); - connection.setRequireSigning(true); - return connection; - } - - private static Opened opened(SmbConnection first, String secondName, SmbConnection second) - throws Exception { - Variables variables = new Variables(); - variables.setVariable("SMB_PASSWORD", PASSWORD); - DefaultFileSystemManager manager = new DefaultFileSystemManager(); - manager.addProvider(first.getName(), new SmbFileProvider(variables, first)); - if (second != null) { - manager.addProvider(secondName, new SmbFileProvider(variables, second)); - } - manager.init(); - return new Opened(manager); - } - - private static final class Opened implements AutoCloseable { - private final DefaultFileSystemManager manager; - - private Opened(DefaultFileSystemManager manager) { - this.manager = manager; - } - - private FileObject resolveFile(String uri) throws FileSystemException { - return manager.resolveFile(uri); - } - - @Override - public void close() { - manager.close(); - } - } - - private static void write(FileObject file, String text) throws Exception { - if (file.exists()) { - file.delete(); - } - file.createFile(); - try (OutputStream out = file.getContent().getOutputStream()) { - out.write(text.getBytes(StandardCharsets.UTF_8)); - } - } -} From 2e102ac76ae72f49d7df35b25471b3dda01efd3b Mon Sep 17 00:00:00 2001 From: mattcasters Date: Sat, 3 Oct 2026 11:40:41 +0200 Subject: [PATCH 3/4] Issue #8671 : Ship bcprov 1.86 in the SMB assembly and release directory leases before the share --- assemblies/static/src/main/resources/LICENSE | 2 +- lib/pom.xml | 2 +- plugins/tech/smb/pom.xml | 7 +- .../org/apache/hop/vfs/smb/SmbjSmbShare.java | 40 ++++-- .../apache/hop/vfs/smb/SmbjSmbShareTest.java | 129 ++++++++++++++++++ 5 files changed, 159 insertions(+), 21 deletions(-) create mode 100644 plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbjSmbShareTest.java diff --git a/assemblies/static/src/main/resources/LICENSE b/assemblies/static/src/main/resources/LICENSE index 39ad05670f0..0cc099aa9db 100644 --- a/assemblies/static/src/main/resources/LICENSE +++ b/assemblies/static/src/main/resources/LICENSE @@ -517,7 +517,7 @@ This product bundles batik-xml 1.19, which is available under a This product bundles bcjmail-jdk18on 1.84, which is available under a "Bouncy Castle Licence" license. For details, see licenses/LICENSE-BOUNCY. -This product bundles bcprov-jdk18on 1.85.2, which is available under a +This product bundles bcprov-jdk18on 1.86, which is available under a "Bouncy Castle Licence" license. For details, see licenses/LICENSE-BOUNCY. This product bundles beam-model-fn-execution 2.74.0, which is available under a diff --git a/lib/pom.xml b/lib/pom.xml index 3f316c8195f..8f79c3218c2 100644 --- a/lib/pom.xml +++ b/lib/pom.xml @@ -38,7 +38,7 @@ 9.10.1 1.12.1 2.20.162 - 1.85.2 + 1.86 3.2.4 3.55.1 0.1 diff --git a/plugins/tech/smb/pom.xml b/plugins/tech/smb/pom.xml index 50f74aed6b9..ae06396b4af 100644 --- a/plugins/tech/smb/pom.xml +++ b/plugins/tech/smb/pom.xml @@ -29,11 +29,6 @@ Hop Plugins Technology SMB SMB2/SMB3 VFS plugin. Named connections only. No SMB1. - - - org.bouncycastle:bcprov-jdk18on - - com.hierynomus @@ -53,7 +48,7 @@ org.bouncycastle bcprov-jdk18on - 1.85.2 + 1.86 provided diff --git a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbjSmbShare.java b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbjSmbShare.java index 106beb8692a..664b38dad7a 100644 --- a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbjSmbShare.java +++ b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbjSmbShare.java @@ -80,8 +80,19 @@ final class SmbjSmbShare implements SmbShare { String domain, String username, AuthenticationContext context) { + this(new SMBClient(config), host, port, shareName, domain, username, context); + } + + SmbjSmbShare( + SMBClient client, + String host, + int port, + String shareName, + String domain, + String username, + AuthenticationContext context) { this.log = LogChannel.GENERAL; - this.client = new SMBClient(config); + this.client = client; this.host = host; this.port = port; this.shareName = shareName; @@ -267,29 +278,19 @@ public void rename(String sharePath, String newSharePath) throws IOException { @Override public void close() { - closeQuietly(disk); - closeQuietly(session); - closeQuietly(connection); + dropSession(); try { client.close(); } catch (Exception e) { logError("Unable to close the SMB client for " + host, e); } - disk = null; - session = null; - connection = null; } private synchronized DiskShare disk() throws IOException { if (disk != null && disk.isConnected()) { return disk; } - closeQuietly(disk); - closeQuietly(session); - closeQuietly(connection); - disk = null; - session = null; - connection = null; + dropSession(); Connection opened = null; Session authenticated = null; try { @@ -367,6 +368,19 @@ private void logError(String message, Exception error) { } } + // Leased directories close through the open share. Connection.close() does it too late. + private synchronized void dropSession() { + if (connection != null && disk != null && disk.isConnected()) { + closeQuietly(connection.getLeaseManager()); + } + closeQuietly(disk); + closeQuietly(session); + closeQuietly(connection); + disk = null; + session = null; + connection = null; + } + private static void closeQuietly(AutoCloseable closeable) { if (closeable == null) { return; diff --git a/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbjSmbShareTest.java b/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbjSmbShareTest.java new file mode 100644 index 00000000000..1ebc89c7ec1 --- /dev/null +++ b/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbjSmbShareTest.java @@ -0,0 +1,129 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.hop.vfs.smb; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.hierynomus.smbj.SMBClient; +import com.hierynomus.smbj.auth.AuthenticationContext; +import com.hierynomus.smbj.connection.Connection; +import com.hierynomus.smbj.connection.LeaseManager; +import com.hierynomus.smbj.session.Session; +import com.hierynomus.smbj.share.DiskShare; +import java.io.IOException; +import java.lang.reflect.Field; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; + +/** Close order, without a live SMB server. Directory leases have to go first. */ +class SmbjSmbShareTest { + + @Test + void closeReleasesDirectoryLeasesWhileTheShareIsOpen() throws Exception { + SMBClient client = mock(SMBClient.class); + SmbjSmbShare share = share(client); + DiskShare disk = mock(DiskShare.class); + Session session = mock(Session.class); + Connection connection = mock(Connection.class); + LeaseManager leases = mock(LeaseManager.class); + AtomicBoolean shareOpen = new AtomicBoolean(true); + when(disk.isConnected()).thenAnswer(invocation -> shareOpen.get()); + doAnswer( + invocation -> { + shareOpen.set(false); + return null; + }) + .when(disk) + .close(); + when(connection.getLeaseManager()).thenReturn(leases); + doAnswer( + invocation -> { + assertTrue(shareOpen.get()); + return null; + }) + .when(leases) + .close(); + bind(share, connection, session, disk); + + share.close(); + + InOrder order = inOrder(connection, leases, disk, session); + order.verify(connection).getLeaseManager(); + order.verify(leases).close(); + order.verify(disk).close(); + order.verify(session).close(); + order.verify(connection).close(); + verify(client).close(); + } + + @Test + void reconnectDropsTheOldSessionBeforeOpeningAnother() throws Exception { + SMBClient client = mock(SMBClient.class); + when(client.connect(anyString(), anyInt())).thenThrow(new IOException("refused")); + SmbjSmbShare share = share(client); + DiskShare disk = mock(DiskShare.class); + Session session = mock(Session.class); + Connection connection = mock(Connection.class); + LeaseManager leases = mock(LeaseManager.class); + when(disk.isConnected()).thenReturn(false); + when(connection.getLeaseManager()).thenReturn(leases); + bind(share, connection, session, disk); + + assertThrows(IOException.class, () -> share.children("")); + + InOrder order = inOrder(disk, session, connection, client); + order.verify(disk).close(); + order.verify(session).close(); + order.verify(connection).close(); + order.verify(client).connect("files.example", 445); + verify(leases, never()).close(); + } + + private static SmbjSmbShare share(SMBClient client) { + return new SmbjSmbShare( + client, + "files.example", + 445, + "data", + "CORP", + "alice", + new AuthenticationContext("alice", "secret".toCharArray(), "CORP")); + } + + private static void bind( + SmbjSmbShare share, Connection connection, Session session, DiskShare disk) throws Exception { + set(share, "connection", connection); + set(share, "session", session); + set(share, "disk", disk); + } + + private static void set(SmbjSmbShare share, String name, Object value) throws Exception { + Field field = SmbjSmbShare.class.getDeclaredField(name); + field.setAccessible(true); + field.set(share, value); + } +} From 958596cc494169fbb6409526b349beef7e63b8da Mon Sep 17 00:00:00 2001 From: mattcasters Date: Mon, 5 Oct 2026 20:59:27 +0200 Subject: [PATCH 4/4] Issue #8671 : Leave the SMB socket timeout at 0 so an idle session stays open --- .../pages/metadata-types/smb-connection.adoc | 2 +- .../metadata/smb-connection/finance-bad.json | 2 +- .../smb-connection/finance-encrypted.json | 2 +- .../smb-connection/finance-missing.json | 2 +- .../smb-connection/finance-rooted.json | 2 +- .../smb/metadata/smb-connection/finance.json | 2 +- .../org/apache/hop/vfs/smb/SmbSettings.java | 25 ++++++++++++++++--- .../hop/vfs/smb/metadata/SmbConnection.java | 4 +-- .../smb/messages/messages_en_US.properties | 1 + .../messages/messages_en_US.properties | 2 +- .../apache/hop/vfs/smb/SmbSupportTest.java | 16 ++++++++++++ 11 files changed, 47 insertions(+), 13 deletions(-) diff --git a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/smb-connection.adoc b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/smb-connection.adoc index 2cdbfa8d148..e65ecca9008 100644 --- a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/smb-connection.adoc +++ b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/smb-connection.adoc @@ -73,5 +73,5 @@ A variable such as `${SMB_PASSWORD}` is resolved at runtime. |Encrypt session|Require SMB3 encryption. |Follow DFS referrals|Follow Distributed File System referrals. Leave off unless the share is a DFS root. |Call timeout|Read, write, and transact timeout in seconds. Default 60. -|Socket timeout|How long a socket read may block, in seconds. Default 60. +|Socket timeout|How long the background reader waits for the next packet, in seconds. 0 waits until the server sends one or drops the connection. Leave this at 0. A positive value closes a quiet session. |=== diff --git a/integration-tests/smb/metadata/smb-connection/finance-bad.json b/integration-tests/smb/metadata/smb-connection/finance-bad.json index 4f540c72578..5199bbc3368 100644 --- a/integration-tests/smb/metadata/smb-connection/finance-bad.json +++ b/integration-tests/smb/metadata/smb-connection/finance-bad.json @@ -14,5 +14,5 @@ "encryptData": false, "dfsEnabled": false, "callTimeoutSeconds": "60", - "socketTimeoutSeconds": "60" + "socketTimeoutSeconds": "0" } diff --git a/integration-tests/smb/metadata/smb-connection/finance-encrypted.json b/integration-tests/smb/metadata/smb-connection/finance-encrypted.json index 2be4140e499..64c0c2b4739 100644 --- a/integration-tests/smb/metadata/smb-connection/finance-encrypted.json +++ b/integration-tests/smb/metadata/smb-connection/finance-encrypted.json @@ -14,5 +14,5 @@ "encryptData": true, "dfsEnabled": false, "callTimeoutSeconds": "60", - "socketTimeoutSeconds": "60" + "socketTimeoutSeconds": "0" } diff --git a/integration-tests/smb/metadata/smb-connection/finance-missing.json b/integration-tests/smb/metadata/smb-connection/finance-missing.json index f1b6727a64d..fe1e2d03c36 100644 --- a/integration-tests/smb/metadata/smb-connection/finance-missing.json +++ b/integration-tests/smb/metadata/smb-connection/finance-missing.json @@ -14,5 +14,5 @@ "encryptData": false, "dfsEnabled": false, "callTimeoutSeconds": "60", - "socketTimeoutSeconds": "60" + "socketTimeoutSeconds": "0" } diff --git a/integration-tests/smb/metadata/smb-connection/finance-rooted.json b/integration-tests/smb/metadata/smb-connection/finance-rooted.json index 125a29d21ba..8bdaa2f5a9b 100644 --- a/integration-tests/smb/metadata/smb-connection/finance-rooted.json +++ b/integration-tests/smb/metadata/smb-connection/finance-rooted.json @@ -14,5 +14,5 @@ "encryptData": false, "dfsEnabled": false, "callTimeoutSeconds": "60", - "socketTimeoutSeconds": "60" + "socketTimeoutSeconds": "0" } diff --git a/integration-tests/smb/metadata/smb-connection/finance.json b/integration-tests/smb/metadata/smb-connection/finance.json index 301a1d7d0e1..5b789e3117f 100644 --- a/integration-tests/smb/metadata/smb-connection/finance.json +++ b/integration-tests/smb/metadata/smb-connection/finance.json @@ -14,5 +14,5 @@ "encryptData": false, "dfsEnabled": false, "callTimeoutSeconds": "60", - "socketTimeoutSeconds": "60" + "socketTimeoutSeconds": "0" } diff --git a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbSettings.java b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbSettings.java index 49c59504cb5..626db79a6a4 100644 --- a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbSettings.java +++ b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/SmbSettings.java @@ -46,6 +46,10 @@ public record SmbSettings( static final int DEFAULT_PORT = 445; static final int DEFAULT_TIMEOUT_SECONDS = 60; + // The reader thread blocks in a socket read between packets. A positive SO_TIMEOUT makes that + // idle read a failed connection, and the cleanup then runs on the reader thread. + static final int DEFAULT_SOCKET_TIMEOUT_SECONDS = 0; + public static SmbSettings resolve(SmbConnection connection, IVariables variables) { String name = Const.NVL(connection.getName(), ""); String host = variables.resolve(Const.NVL(connection.getHostname(), "")).trim(); @@ -64,9 +68,9 @@ public static SmbSettings resolve(SmbConnection connection, IVariables variables name, "call timeout"); int socketTimeout = - positiveInt( + nonNegativeInt( variables.resolve(connection.getSocketTimeoutSeconds()), - DEFAULT_TIMEOUT_SECONDS, + DEFAULT_SOCKET_TIMEOUT_SECONDS, name, "socket timeout"); SmbAuthType authType = @@ -105,18 +109,31 @@ public SmbConfig toClientConfig() { } private static int positiveInt(String raw, int fallback, String name, String field) { + return number(raw, fallback, name, field, false); + } + + private static int nonNegativeInt(String raw, int fallback, String name, String field) { + return number(raw, fallback, name, field, true); + } + + private static int number( + String raw, int fallback, String name, String field, boolean allowZero) { if (raw == null || raw.isBlank()) { return fallback; } try { int value = Integer.parseInt(raw.trim()); - if (value <= 0) { + if (value < 0 || (!allowZero && value == 0)) { throw new NumberFormatException(field); } return value; } catch (NumberFormatException e) { throw new IllegalArgumentException( - BaseMessages.getString(PKG, "Smb.Error.PositiveNumber", name, field)); + BaseMessages.getString( + PKG, + allowZero ? "Smb.Error.NonNegativeNumber" : "Smb.Error.PositiveNumber", + name, + field)); } } } diff --git a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/metadata/SmbConnection.java b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/metadata/SmbConnection.java index b0ced5c228e..3a6107d53b4 100644 --- a/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/metadata/SmbConnection.java +++ b/plugins/tech/smb/src/main/java/org/apache/hop/vfs/smb/metadata/SmbConnection.java @@ -296,14 +296,14 @@ public void testConnection(Object object) { group = GROUP_SECURITY, groupOrder = "030") @HopMetadataProperty - private String socketTimeoutSeconds = "60"; + private String socketTimeoutSeconds = "0"; public SmbConnection() { this.port = "445"; this.authType = SmbAuthType.NTLM; this.minimumDialect = SmbDialect.SMB_2_0_2; this.callTimeoutSeconds = "60"; - this.socketTimeoutSeconds = "60"; + this.socketTimeoutSeconds = "0"; } @Override diff --git a/plugins/tech/smb/src/main/resources/org/apache/hop/vfs/smb/messages/messages_en_US.properties b/plugins/tech/smb/src/main/resources/org/apache/hop/vfs/smb/messages/messages_en_US.properties index 58329518c6f..2e3baf23436 100644 --- a/plugins/tech/smb/src/main/resources/org/apache/hop/vfs/smb/messages/messages_en_US.properties +++ b/plugins/tech/smb/src/main/resources/org/apache/hop/vfs/smb/messages/messages_en_US.properties @@ -17,6 +17,7 @@ Smb.Error.Hostname = SMB connection "{0}" needs a hostname Smb.Error.Share = SMB connection "{0}" needs a share name Smb.Error.PositiveNumber = SMB connection "{0}" {1} must be a positive number +Smb.Error.NonNegativeNumber = SMB connection "{0}" {1} must be zero or a positive number Smb.Error.Username = SMB connection "{0}" needs a username Smb.Error.PathEscapes = The path leaves the folder this SMB connection is rooted at: {0} Smb.Test.Ok = Connected to {0}:{1}, share "{2}" diff --git a/plugins/tech/smb/src/main/resources/org/apache/hop/vfs/smb/metadata/messages/messages_en_US.properties b/plugins/tech/smb/src/main/resources/org/apache/hop/vfs/smb/metadata/messages/messages_en_US.properties index c15f8c33f07..0d9902bfd41 100644 --- a/plugins/tech/smb/src/main/resources/org/apache/hop/vfs/smb/metadata/messages/messages_en_US.properties +++ b/plugins/tech/smb/src/main/resources/org/apache/hop/vfs/smb/metadata/messages/messages_en_US.properties @@ -50,4 +50,4 @@ Smb.Dfs.Tooltip = Let the client follow Distributed File System referrals. Leave Smb.CallTimeout.Label = Call timeout (seconds) Smb.CallTimeout.Tooltip = Read, write, and transact timeout. Default 60. Example: '${SMB_TIMEOUT}' Smb.SocketTimeout.Label = Socket timeout (seconds) -Smb.SocketTimeout.Tooltip = How long a socket read may block. Default 60. A value of 0 is not accepted. +Smb.SocketTimeout.Tooltip = How long the background reader waits for the next packet. 0 waits until the server sends one or drops the connection. Leave this at 0. A positive value closes a quiet session. Example: '${SMB_SOCKET_TIMEOUT}' diff --git a/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbSupportTest.java b/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbSupportTest.java index 71cbaa19025..6da3ec7dcd6 100644 --- a/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbSupportTest.java +++ b/plugins/tech/smb/src/test/java/org/apache/hop/vfs/smb/SmbSupportTest.java @@ -168,6 +168,22 @@ void defaultDialectStartsAtSmb2() { assertFalse(config.isSigningRequired()); assertFalse(config.isEncryptData()); assertFalse(config.isDfsEnabled()); + assertEquals(60_000L, config.getReadTimeout()); + assertEquals(0, config.getSoTimeout()); + } + + @Test + void socketTimeoutZeroLeavesTheReaderWaiting() { + Variables variables = new Variables(); + SmbConnection connection = connection(); + connection.setSocketTimeoutSeconds("0"); + assertEquals(0, SmbSettings.resolve(connection, variables).toClientConfig().getSoTimeout()); + + connection.setSocketTimeoutSeconds(" "); + assertEquals(0, SmbSettings.resolve(connection, variables).toClientConfig().getSoTimeout()); + + connection.setSocketTimeoutSeconds("-1"); + assertThrows(IllegalArgumentException.class, () -> SmbSettings.resolve(connection, variables)); } @Test