<PackageReference Include="Relativity.Transfer.Client" Version="6.0.16" />

AsperaDiagnosticsService

using Polly; using Relativity.Transfer.Aspera.Resources; using Relativity.Transfer.Dto; using Renci.SshNet; using Renci.SshNet.Common; using System; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; namespace Relativity.Transfer.Aspera { internal class AsperaDiagnosticsService { public const int DefaultMaxTimeoutSeconds = 120; public const string AsperaLogFileName = "aspera-scp-transfer.log"; private const int SuccessfulExitCode = 0; private readonly AsperaClientConfiguration configuration; private readonly DiagnosticsContext diagnosticContext; private readonly IFileSystemService fileSystemService; private readonly RelativityConnectionInfo relativityConnectionInfo; private readonly AsperaCredential asperaCredential; private readonly ITransferLog transferLog; public int MaxTimeoutSeconds { get; set; } public AsperaDiagnosticsService(RelativityConnectionInfo relativityConnectionInfo, AsperaClientConfiguration configuration, DiagnosticsContext context, IFileSystemService fileSystemService, ITransferLog log) { if (relativityConnectionInfo == null) throw new ArgumentNullException("relativityConnectionInfo"); if (configuration == null) throw new ArgumentNullException("configuration"); if (context == null) throw new ArgumentNullException("context"); if (fileSystemService == null) throw new ArgumentNullException("fileSystemService"); if (log == null) throw new ArgumentNullException("log"); MaxTimeoutSeconds = 120; this.relativityConnectionInfo = relativityConnectionInfo; this.configuration = configuration; diagnosticContext = context; this.fileSystemService = fileSystemService; transferLog = log; asperaCredential = new AsperaCredential(configuration.Credential); } public Task<ISupportCheckResult> TestConnectivityAsync(CancellationToken token) { return TestConnectivityAsync(configuration.TestConnectionDestinationPath, token); } public Task<ISupportCheckResult> TestConnectivityAsync(string targetPath, CancellationToken token) { if (string.IsNullOrEmpty(targetPath)) throw new ArgumentNullException("targetPath"); return Task.Run((Func<Task<ISupportCheckResult>>)async delegate { using (TempDirectory tempDirectory = new TempDirectory()) { SupportCheckResult result = new SupportCheckResult(); tempDirectory.Create(); string fileName = GetTestUploadFileName(); TransferPath path = new TransferPath { SourcePath = CreateTestUploadFile(tempDirectory, fileName), TargetPath = targetPath, Direction = TransferDirection.Upload }; await PerformZeroByteTransferAsync(tempDirectory, path, result, false, token).ConfigureAwait(false); if (string.IsNullOrEmpty(result.ErrorMessage)) { TransferPath path2 = new TransferPath { SourcePath = PathHelper.CombineUnix(targetPath, fileName), TargetPath = tempDirectory.Directory, Direction = TransferDirection.Download }; await PerformZeroByteTransferAsync(tempDirectory, path2, result, true, token).ConfigureAwait(false); if (string.IsNullOrEmpty(result.ErrorMessage)) { string text = Path.Combine(tempDirectory.Directory, fileName); if (!File.Exists(text)) { LogInformation($"""{text}"""); result.IsSupported = false; result.ErrorMessage = string.Format(CultureInfo.CurrentCulture, AsperaStrings.AsperaTestConnectionFileNotFoundExceptionMessage, GetHostName()); } } else TestPorts(result, token); } else TestPorts(result, token); return result; } }, token); } public Task<ISupportCheckResult> TestPortsAsync(CancellationToken token) { SupportCheckResult result = new SupportCheckResult(); return Task.Run((Func<ISupportCheckResult>)delegate { TestPorts(result, token); return result; }, token); } public Task<ISupportCheckResult> TestFaspUploadAsync(TempDirectory tempDirectory, string sourcePath, string destinationPath, CancellationToken token) { return Task.Run((Func<Task<ISupportCheckResult>>)async delegate { SupportCheckResult result = new SupportCheckResult(); TransferPath path = new TransferPath { Direction = TransferDirection.Upload, SourcePath = sourcePath, TargetPath = destinationPath }; await PerformZeroByteTransferAsync(tempDirectory, path, result, false, token).ConfigureAwait(false); return result; }, token); } public Task<ISupportCheckResult> TestFaspDownloadAsync(TempDirectory tempDirectory, string sourcePath, string destinationPath, CancellationToken token) { return Task.Run((Func<Task<ISupportCheckResult>>)async delegate { SupportCheckResult result = new SupportCheckResult(); TransferPath path = new TransferPath { Direction = TransferDirection.Download, SourcePath = sourcePath, TargetPath = destinationPath }; await PerformZeroByteTransferAsync(tempDirectory, path, result, true, token).ConfigureAwait(false); return result; }, token); } private static string GetTestUploadFileName() { return $"""{Environment.MachineName.ToUpperInvariant()}""{Environment.UserName.ToUpperInvariant()}"""; } private static string CreateTestUploadFile(TempDirectory tempDirectory, string fileName) { string text = Path.Combine(tempDirectory.Directory, fileName); using (FileStream fileStream = new FileStream(text, FileMode.CreateNew)) { fileStream.Seek(8192, SeekOrigin.Begin); fileStream.WriteByte(13); fileStream.Flush(true); return text; } } private string GetRemoteTestUploadFilePath(TransferPath path) { if (path.Direction == TransferDirection.Upload) { string fileName = fileSystemService.GetFileName(path.SourcePath); return PathHelper.CombineUnix(path.TargetPath, fileName); } return path.SourcePath; } [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "This is done by design. The result includes the error message.")] private async Task PerformZeroByteTransferAsync(TempDirectory tempDirectory, TransferPath path, SupportCheckResult result, bool considerDeleteTestfile, CancellationToken token) { if (!token.IsCancellationRequested) { Uri hostName = GetHostName(); string accountUserName = GetAccountUserName(); LogInformation($"""{path.Direction}""{hostName}""{configuration.TcpPort}""{configuration.UdpPortStartRange}"); StringBuilder commandLine = new StringBuilder(); commandLine.AppendFormat(" -P {0}", configuration.TcpPort); commandLine.AppendFormat(" -O {0}", configuration.UdpPortStartRange); commandLine.Append(" --policy=fair"); commandLine.Append(" -Q"); commandLine.Append(" -T"); commandLine.Append(" -d"); commandLine.Append(" -p"); commandLine.Append(" -q"); commandLine.Append(" -c aes256"); commandLine.AppendFormat(" -l {0}", configuration.TargetDataRateMbps); commandLine.AppendFormat(" -L {0}", tempDirectory.Directory); switch (path.Direction) { case TransferDirection.Upload: commandLine.Append(" --no-write"); break; case TransferDirection.Download: commandLine.Append(" --no-read"); break; } commandLine.AppendFormat(" --mode={0}", (path.Direction == TransferDirection.Upload) ? "send" : "recv"); commandLine.AppendFormat(" --user={0}", accountUserName); commandLine.AppendFormat(" --host={0}", hostName); commandLine.AppendFormat(" \"{0}\"", path.SourcePath); commandLine.AppendFormat(" \"{0}\"", path.TargetPath); AsperaRuntime runtime = new AsperaRuntime(); runtime.Install(configuration); int faspExitCode = 0; try { faspExitCode = await RetryTResultSyntaxAsync.WaitAndRetryAsync<int>(Policy.HandleResult<int>((Func<int, bool>)((int x) => x != 0)).Or<Exception>(), 2, (Func<int, TimeSpan>)((int retryAttempt) => TimeSpan.FromSeconds(Math.Pow(2, (double)retryAttempt))), (Action<DelegateResult<int>, TimeSpan, Context>)delegate(DelegateResult<int> exception, TimeSpan timespan, Context context) { LogError($"""{timespan}""", exception.get_Exception()); }).ExecuteAsync((Func<CancellationToken, Task<int>>)((CancellationToken cancellationToken) => Task.FromResult(ExecProcess(path.Direction, commandLine, runtime.Paths, tempDirectory, result))), token).ConfigureAwait(false); } finally { if (faspExitCode == 0) { if (path.Direction == TransferDirection.Upload) await TryVerifyUploadAsync(path, result, token).ConfigureAwait(false); if (considerDeleteTestfile) await TryDeleteTestFile(path, result, token).ConfigureAwait(false); } } } } [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "This is done by design. The result includes the error message.")] private void TestSshPort(SupportCheckResult result, CancellationToken token) { if (!token.IsCancellationRequested) { Uri hostName = GetHostName(); int tcpPort = configuration.TcpPort; string accountUserName = GetAccountUserName(); string accountPassword = GetAccountPassword(); PasswordConnectionInfo val = new PasswordConnectionInfo(hostName.ToString(), tcpPort, accountUserName, accountPassword); try { val.set_Timeout(TimeSpan.FromSeconds((double)MaxTimeoutSeconds)); LogInformation($"""{tcpPort}""{hostName}"""); SshClient val2 = new SshClient(val); try { val2.Connect(); LogInformation($"""{tcpPort}""{hostName}"""); result.IsSupported = true; } catch (Exception ex) { string message = $"""{tcpPort}""{hostName}"""; result.IsSupported = false; result.ErrorMessage = string.Format(CultureInfo.CurrentCulture, AsperaStrings.AsperaTestConnectionFailedSshPortMessage, hostName, tcpPort); object obj = (object)(ex as SshAuthenticationException); SocketException ex2 = ex as SocketException; if (obj != null) { message = $"""{tcpPort}""{hostName}"""; result.IsSupported = false; result.ErrorMessage = string.Format(CultureInfo.CurrentCulture, AsperaStrings.AsperaTestConnectionAuthenticationMessage, hostName, tcpPort); } else if (ex2 != null) { result.IsSupported = false; switch (ex2.SocketErrorCode) { case SocketError.ConnectionRefused: result.ErrorMessage = string.Format(CultureInfo.CurrentCulture, AsperaStrings.AsperaTestConnectionFailedInvalidPortOrBlockedMessage, hostName, tcpPort); break; case SocketError.TimedOut: result.ErrorMessage = string.Format(CultureInfo.CurrentCulture, AsperaStrings.AsperaTestConnectionFailedInvalidPortOrHostOrServerDownMessage, hostName, tcpPort); break; default: result.ErrorMessage = string.Format(CultureInfo.CurrentCulture, AsperaStrings.AsperaTestConnectionFailedSshPortMessage, hostName, tcpPort); break; } } LogError(message, ex); } finally { ((IDisposable)val2)?.Dispose(); } } finally { ((IDisposable)val)?.Dispose(); } } } [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "This is done by design. The result includes the error message.")] private void TestUdpPort(SupportCheckResult result, CancellationToken token) { if (!token.IsCancellationRequested) { Uri host = asperaCredential.Host; int udpPortStartRange = configuration.UdpPortStartRange; LogInformation($"""{udpPortStartRange}""{host}"""); UdpClient udpClient = new UdpClient(); try { udpClient.Client.ReceiveTimeout = MaxTimeoutSeconds * 1000; udpClient.Connect(host.ToString(), udpPortStartRange); LogInformation($"""{udpPortStartRange}""{host}"""); } catch (Exception) { result.IsSupported = false; result.ErrorMessage = string.Format(CultureInfo.CurrentCulture, AsperaStrings.AsperaTestConnectionFailedUdpPortMessage, GetHostName(), GetUdpPortRangeString()); } finally { udpClient.Close(); } } } private void TestPorts(SupportCheckResult result, CancellationToken token) { TestSshPort(result, token); if (result.IsSupported && !token.IsCancellationRequested) { TestUdpPort(result, token); if (result.IsSupported && !token.IsCancellationRequested) result.IsSupported = true; } } [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "This is an expectations for diagnostics.")] private int ExecProcess(TransferDirection direction, StringBuilder commandLine, AsperaRuntimePaths paths, TempDirectory tempDirectory, SupportCheckResult result) { DataReceivedEventHandler value = delegate(object sender, DataReceivedEventArgs args) { if (!string.IsNullOrEmpty(args.Data)) result.AddStandardOutput(args.Data); }; DataReceivedEventHandler value2 = delegate(object sender, DataReceivedEventArgs args) { if (!string.IsNullOrEmpty(args.Data)) result.AddStandardError(args.Data); }; using (Process process = new Process()) try { string accountPassword = GetAccountPassword(); ProcessStartInfo processStartInfo = new ProcessStartInfo(); processStartInfo.EnvironmentVariables["ASPERA_SCP_PASS"] = accountPassword; processStartInfo.EnvironmentVariables["ASPERA_SCP_COOKIE"] = AsperaFaspService.EncodeCookieString(string.Format("{0}-connection-check", "transfer-api"), Guid.NewGuid()); processStartInfo.FileName = paths.AsperaEngineFile; processStartInfo.Arguments = commandLine.ToString(); processStartInfo.CreateNoWindow = true; processStartInfo.WindowStyle = ProcessWindowStyle.Hidden; processStartInfo.RedirectStandardError = true; processStartInfo.RedirectStandardOutput = true; processStartInfo.UseShellExecute = false; processStartInfo.WorkingDirectory = paths.BinDirectory; process.StartInfo = processStartInfo; process.EnableRaisingEvents = true; process.OutputDataReceived += value; process.ErrorDataReceived += value2; LogInformation($"""{direction}"""); process.Start(); process.BeginOutputReadLine(); process.BeginErrorReadLine(); int num = 0; try { process.WaitForExit(MaxTimeoutSeconds * 1000); if (!process.HasExited) process.Kill(); num = process.ExitCode; } catch (Exception ex) { LogError($"""{direction}""{num}", ex); throw new InvalidOperationException(AsperaStrings.AsperaTestConnectionProcessExceptionMessage, ex); } if (num == 0) LogInformation($"""{direction}""{num}"); else LogError($"""{direction}""{num}", (Exception)null); string text = Path.Combine(tempDirectory.Directory, "aspera-scp-transfer.log"); if (!File.Exists(text)) throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, AsperaStrings.AsperaTestConnectionLogFileNotFoundExceptionMessage, text)); LogInformation($"""{text}"""); string[] array = File.ReadAllLines(text); LogInformation($"""{array.Length}"""); string[] array2 = File.ReadAllLines(text); foreach (string line in array2) { result.AddLogFileLine(line); } if (num == 0) { result.ErrorMessage = string.Empty; result.IsSupported = true; } else { result.ErrorMessage = string.Format(CultureInfo.CurrentCulture, AsperaStrings.AsperaTestConnectionFaspFailedMessage, GetHostName(), configuration.TcpPort, GetUdpPortRangeString()); result.IsSupported = false; } return num; } catch (InvalidOperationException exception) { LogError($"""{direction}""", exception); result.ErrorMessage = string.Format(CultureInfo.CurrentCulture, AsperaStrings.AsperaTestConnectionEnvironmentExceptionMessage, GetHostName()); result.IsSupported = false; return -1; } catch (Exception exception2) { LogError($"""{direction}""", exception2); result.ErrorMessage = string.Format(CultureInfo.CurrentCulture, AsperaStrings.AsperaTestConnectionExceptionMessage, GetHostName()); result.IsSupported = false; return -1; } finally { process.OutputDataReceived -= value; process.ErrorDataReceived -= value2; } } private string GetUdpPortRangeString() { return string.Format(CultureInfo.InvariantCulture, "{0}-{1}", configuration.UdpPortStartRange, configuration.UdpPortEndRange); } private void LogError(string message, Exception exception = null) { transferLog.LogError(exception, "Aspera Test Connection - {Message}", message); } private void LogError(string message, AsperaErrorDto error) { transferLog.LogError("Aspera Test Connection - {Message} - Aspera error: Code={Code}, Reason={Reason}, UserMessage={UserMessage}", message, error.Code, error.Reason, error.UserMessage); } private void LogInformation(string message) { transferLog.LogInformation("Aspera Test Connection - {Message}", message); } private string GetAccountUserName() { string text = asperaCredential.AccountUserName.UnprotectData(); if (!string.IsNullOrEmpty(text)) return text; throw new InvalidOperationException(AsperaStrings.AsperaTestConnectionUserNameExceptionMessage); } private string GetAccountPassword() { string text = asperaCredential.AccountPassword.UnprotectData(); if (!string.IsNullOrEmpty(text)) return text; throw new InvalidOperationException(AsperaStrings.AsperaTestConnectionPasswordExceptionMessage); } private Uri GetHostName() { Uri host = asperaCredential.Host; if (host != (Uri)null) return host; throw new InvalidOperationException(AsperaStrings.AsperaTestConnectionHostExceptionMessage); } [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "This is OK for a try-based method.")] private async Task TryVerifyUploadAsync(TransferPath testFilePath, SupportCheckResult result, CancellationToken token) { if (diagnosticContext.Configuration.UploadValidation) try { string unixTargetPath = GetRemoteTestUploadFilePath(testFilePath); RelativityFileShareBase fileShare = await GetFileShare(result, token).ConfigureAwait(false); if (string.IsNullOrEmpty(result.ErrorMessage)) { AsperaNodeService asperaNodeService = CreateAsperaNodeService(fileShare, result); if (string.IsNullOrEmpty(result.ErrorMessage)) { string fileName = fileSystemService.GetFileName(testFilePath.SourcePath); BrowseDirectoryResponseDto browseDirectoryResponseDto = await asperaNodeService.GetFileExistsAsync(testFilePath.TargetPath, fileName, token).ConfigureAwait(false); if (browseDirectoryResponseDto.Error != null) { LogError($"""{unixTargetPath}""{fileShare.DocRoot}", browseDirectoryResponseDto.Error); string message = string.Format(CultureInfo.CurrentCulture, AsperaStrings.AsperaTestConnectionNodeExistsErrorExceptionMessage, unixTargetPath, fileShare.Name); result.IsSupported = false; result.ErrorMessage = AsperaErrorHelper.GetDisplayErrorMessage(browseDirectoryResponseDto.Error, message); } else { if (browseDirectoryResponseDto.ItemCount > 0) LogInformation("The Node API file exists check operation is successful."); else { LogError($"""{unixTargetPath}""{fileShare.DocRoot}", (Exception)null); result.IsSupported = false; result.ErrorMessage = string.Format(CultureInfo.CurrentCulture, AsperaStrings.AsperaTestConnectionNodeFileNotFoundExceptionMessage, unixTargetPath, fileShare.Name); } if (diagnosticContext.Configuration.DirectFileExistCheck) { if (fileSystemService.FileExists(fileSystemService.Combine(testFilePath.TargetPath, fileName))) LogInformation("The file system service API exists check operation is successful."); else { LogError($"""{unixTargetPath}""{fileShare.DocRoot}", (Exception)null); result.IsSupported = false; result.ErrorMessage = string.Format(CultureInfo.CurrentCulture, AsperaStrings.AsperaTestConnectionNodeFileNotFoundExceptionMessage, unixTargetPath, fileShare.Name); } } } } } } catch (Exception ex) { LogError("The Node API file exists check operation experienced an unexpected error.", ex); result.IsSupported = false; result.ErrorMessage = string.Format(CultureInfo.CurrentCulture, AsperaStrings.AsperaTestConnectionFileExistsNodeFailedExceptionMessage, asperaCredential.Host, ex.Message); } } [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "This is OK for a try-based method.")] private async Task TryDeleteTestFile(TransferPath testFilePath, SupportCheckResult result, CancellationToken token) { if (diagnosticContext.Configuration.DeleteUploadTestFiles) try { RelativityFileShareBase fileShare = await GetFileShare(result, token).ConfigureAwait(false); if (string.IsNullOrEmpty(result.ErrorMessage)) { AsperaNodeService asperaNodeService = CreateAsperaNodeService(fileShare, result); if (string.IsNullOrEmpty(result.ErrorMessage)) { string unixTargetPath = GetRemoteTestUploadFilePath(testFilePath); DeletePathResponseDto deletePathResponseDto = await asperaNodeService.DeleteFileAsync(unixTargetPath, token).ConfigureAwait(false); if (deletePathResponseDto.Error != null) { LogError($"""{unixTargetPath}""{fileShare.DocRoot}", deletePathResponseDto.Error); string message = string.Format(CultureInfo.CurrentCulture, AsperaStrings.AsperaTestConnectionNodeDeleteFileErrorExceptionMessage, unixTargetPath, fileShare.Name); result.IsSupported = false; result.ErrorMessage = AsperaErrorHelper.GetDisplayErrorMessage(deletePathResponseDto.Error, message); } else if (deletePathResponseDto.Paths.Count < 1) { LogError($"""{unixTargetPath}""{fileShare.DocRoot}", (Exception)null); result.IsSupported = false; result.ErrorMessage = string.Format(CultureInfo.CurrentCulture, AsperaStrings.AsperaTestConnectionNodeDeleteFileErrorExceptionMessage, unixTargetPath, fileShare.Name); } else { LogInformation("The Node API delete test file operation is successful."); } } } } catch (Exception ex) { LogError("The Node API delete test file operation experienced an unexpected error.", ex); result.IsSupported = false; result.ErrorMessage = string.Format(CultureInfo.CurrentCulture, AsperaStrings.AsperaTestConnectionDeleteFileNodeFailedExceptionMessage, asperaCredential.Host, ex.Message); } } [SuppressMessage("Microsoft.Naming", "CA2204:Literals should be spelled correctly", MessageId = "DocRoot", Justification = "This is an accepted term.")] private AsperaNodeService CreateAsperaNodeService(RelativityFileShareBase fileShare, SupportCheckResult result) { if (fileShare.NodeCredential == (AsperaCredential)null) { LogError($"""{fileShare.Name}""{fileShare.DocRoot}", (Exception)null); result.IsSupported = false; result.ErrorMessage = string.Format(CultureInfo.CurrentCulture, AsperaStrings.AsperaTestConnectionNodeCredentialNotFoundExceptionMessage, fileShare.Name); return null; } return new AsperaNodeService(fileShare.NodeCredential.HttpConnectionInfo, transferLog, configuration.MaxHttpRetryAttempts, configuration.HttpTimeoutSeconds); } private async Task<RelativityFileShareBase> GetFileShare(SupportCheckResult result, CancellationToken token) { FileStorageSearch fileStorageSearch = new FileStorageSearch(relativityConnectionInfo, transferLog, configuration.MaxHttpRetryAttempts, configuration.HttpTimeoutSeconds); FileStorageSearchContext empty = FileStorageSearchContext.Empty; if (relativityConnectionInfo.WorkspaceId > 0) empty.WorkspaceId = relativityConnectionInfo.WorkspaceId; if (configuration.TargetFileShare != null) return configuration.TargetFileShare; FileStorageSearchResults fileStorageSearchResults = await fileStorageSearch.SearchAsync(empty, token).ConfigureAwait(false); RelativityFileShareBase relativityFileShareBase = (RelativityFileShareBase)(((object)fileStorageSearchResults.GetRelativityFileShare(asperaCredential)) ?? ((object)fileStorageSearchResults.GetRelativityBulkLoadFileShare(asperaCredential))); if (relativityFileShareBase != null) return relativityFileShareBase; if (fileStorageSearchResults.InvalidFileShares.Count > 0 || fileStorageSearchResults.InvalidBulkLoadFileShare.Count > 0) { string text = string.Join(",", (from x in fileStorageSearchResults.InvalidFileShares select x.Name).Concat(from x in fileStorageSearchResults.InvalidBulkLoadFileShare select x.Name)); LogError($"""{asperaCredential.Host}""{asperaCredential.AccountUserName.UnprotectData()}""{text}", (Exception)null); result.IsSupported = false; result.ErrorMessage = string.Format(CultureInfo.CurrentCulture, AsperaStrings.AsperaTestConnectionFileShareNotFoundAndInvalidFileSharesExceptionMessage, asperaCredential.Host, text); return null; } LogError($"""{asperaCredential.Host}""{asperaCredential.AccountUserName.UnprotectData()}""", (Exception)null); result.IsSupported = false; result.ErrorMessage = string.Format(CultureInfo.CurrentCulture, AsperaStrings.AsperaTestConnectionFileShareNotFoundExceptionMessage, asperaCredential.Host); return null; } } }