001/*
002 * Copyright 2017-2024 Ping Identity Corporation
003 * All Rights Reserved.
004 */
005/*
006 * Copyright 2017-2024 Ping Identity Corporation
007 *
008 * Licensed under the Apache License, Version 2.0 (the "License");
009 * you may not use this file except in compliance with the License.
010 * You may obtain a copy of the License at
011 *
012 *    http://www.apache.org/licenses/LICENSE-2.0
013 *
014 * Unless required by applicable law or agreed to in writing, software
015 * distributed under the License is distributed on an "AS IS" BASIS,
016 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
017 * See the License for the specific language governing permissions and
018 * limitations under the License.
019 */
020/*
021 * Copyright (C) 2017-2024 Ping Identity Corporation
022 *
023 * This program is free software; you can redistribute it and/or modify
024 * it under the terms of the GNU General Public License (GPLv2 only)
025 * or the terms of the GNU Lesser General Public License (LGPLv2.1 only)
026 * as published by the Free Software Foundation.
027 *
028 * This program is distributed in the hope that it will be useful,
029 * but WITHOUT ANY WARRANTY; without even the implied warranty of
030 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
031 * GNU General Public License for more details.
032 *
033 * You should have received a copy of the GNU General Public License
034 * along with this program; if not, see <http://www.gnu.org/licenses>.
035 */
036package com.unboundid.ldap.sdk.unboundidds.tools;
037
038
039
040import java.io.File;
041import java.io.FileInputStream;
042import java.io.PrintStream;
043import java.nio.ByteBuffer;
044import java.nio.channels.FileChannel;
045import java.nio.channels.FileLock;
046import java.nio.file.StandardOpenOption;
047import java.nio.file.attribute.FileAttribute;
048import java.nio.file.attribute.PosixFilePermission;
049import java.nio.file.attribute.PosixFilePermissions;
050import java.text.SimpleDateFormat;
051import java.util.Collections;
052import java.util.Date;
053import java.util.EnumSet;
054import java.util.HashSet;
055import java.util.List;
056import java.util.Properties;
057import java.util.Set;
058
059import com.unboundid.util.Debug;
060import com.unboundid.util.NotNull;
061import com.unboundid.util.Nullable;
062import com.unboundid.util.ObjectPair;
063import com.unboundid.util.StaticUtils;
064import com.unboundid.util.ThreadSafety;
065import com.unboundid.util.ThreadSafetyLevel;
066
067import static com.unboundid.ldap.sdk.unboundidds.tools.ToolMessages.*;
068
069
070
071/**
072 * This class provides a utility that can log information about the launch and
073 * completion of a tool invocation.
074 * <BR>
075 * <BLOCKQUOTE>
076 *   <B>NOTE:</B>  This class, and other classes within the
077 *   {@code com.unboundid.ldap.sdk.unboundidds} package structure, are only
078 *   supported for use against Ping Identity, UnboundID, and
079 *   Nokia/Alcatel-Lucent 8661 server products.  These classes provide support
080 *   for proprietary functionality or for external specifications that are not
081 *   considered stable or mature enough to be guaranteed to work in an
082 *   interoperable way with other types of LDAP servers.
083 * </BLOCKQUOTE>
084 */
085@ThreadSafety(level=ThreadSafetyLevel.COMPLETELY_THREADSAFE)
086public final class ToolInvocationLogger
087{
088  /**
089   * The format string that should be used to format log message timestamps.
090   */
091  @NotNull private static final String LOG_MESSAGE_DATE_FORMAT =
092       "dd/MMM/yyyy:HH:mm:ss.SSS Z";
093
094  /**
095   * The name of a system property that can be used to specify an alternate
096   * instance root path for testing purposes.
097   */
098  @NotNull static final String PROPERTY_TEST_INSTANCE_ROOT =
099          ToolInvocationLogger.class.getName() + ".testInstanceRootPath";
100
101  /**
102   * Prevent this utility class from being instantiated.
103   */
104  private ToolInvocationLogger()
105  {
106    // No implementation is required.
107  }
108
109
110
111  /**
112   * Retrieves an object with a set of information about the invocation logging
113   * that should be performed for the specified tool, if any.
114   *
115   * @param  commandName      The name of the command (without any path
116   *                          information) for the associated tool.  It must not
117   *                          be {@code null}.
118   * @param  logByDefault     Indicates whether the tool indicates that
119   *                          invocation log messages should be generated for
120   *                          the specified tool by default.  This may be
121   *                          overridden by content in the
122   *                          {@code tool-invocation-logging.properties} file,
123   *                          but it will be used in the absence of the
124   *                          properties file or if the properties file does not
125   *                          specify whether logging should be performed for
126   *                          the specified tool.
127   * @param  toolErrorStream  A print stream that may be used to report
128   *                          information about any problems encountered while
129   *                          attempting to perform invocation logging.  It
130   *                          must not be {@code null}.
131   *
132   * @return  An object with a set of information about the invocation logging
133   *          that should be performed for the specified tool.  The
134   *          {@link ToolInvocationLogDetails#logInvocation()} method may
135   *          be used to determine whether invocation logging should be
136   *          performed.
137   */
138  @NotNull()
139  public static ToolInvocationLogDetails getLogMessageDetails(
140              @NotNull final String commandName,
141              final boolean logByDefault,
142              @NotNull final PrintStream toolErrorStream)
143  {
144    // Try to figure out the path to the server instance root.  In production
145    // code, we'll look for an INSTANCE_ROOT environment variable to specify
146    // that path, but to facilitate unit testing, we'll allow it to be
147    // overridden by a Java system property so that we can have our own custom
148    // path.
149    String instanceRootPath =
150         StaticUtils.getSystemProperty(PROPERTY_TEST_INSTANCE_ROOT);
151    if (instanceRootPath == null)
152    {
153      instanceRootPath = StaticUtils.getEnvironmentVariable("INSTANCE_ROOT");
154      if (instanceRootPath == null)
155      {
156        return ToolInvocationLogDetails.createDoNotLogDetails(commandName);
157      }
158    }
159
160    final File instanceRootDirectory =
161         new File(instanceRootPath).getAbsoluteFile();
162    if ((!instanceRootDirectory.exists()) ||
163         (!instanceRootDirectory.isDirectory()))
164    {
165      return ToolInvocationLogDetails.createDoNotLogDetails(commandName);
166    }
167
168
169    // Construct the paths to the default tool invocation log file and to the
170    // logging properties file.
171    final boolean canUseDefaultLog;
172    final File defaultToolInvocationLogFile = StaticUtils.constructPath(
173         instanceRootDirectory, "logs", "tools", "tool-invocation.log");
174    if (defaultToolInvocationLogFile.exists())
175    {
176      canUseDefaultLog = defaultToolInvocationLogFile.isFile();
177    }
178    else
179    {
180      final File parentDirectory = defaultToolInvocationLogFile.getParentFile();
181      canUseDefaultLog =
182           (parentDirectory.exists() && parentDirectory.isDirectory());
183    }
184
185    final File invocationLoggingPropertiesFile = StaticUtils.constructPath(
186         instanceRootDirectory, "config", "tool-invocation-logging.properties");
187
188
189    // If the properties file doesn't exist, then just use the logByDefault
190    // setting in conjunction with the default tool invocation log file.
191    if (!invocationLoggingPropertiesFile.exists())
192    {
193      if (logByDefault && canUseDefaultLog)
194      {
195        return ToolInvocationLogDetails.createLogDetails(commandName, null,
196             Collections.singleton(defaultToolInvocationLogFile),
197             toolErrorStream);
198      }
199      else
200      {
201        return ToolInvocationLogDetails.createDoNotLogDetails(commandName);
202      }
203    }
204
205
206    // Load the properties file.  If this fails, then report an error and do not
207    // attempt any additional logging.
208    final Properties loggingProperties = new Properties();
209    try (FileInputStream inputStream =
210              new FileInputStream(invocationLoggingPropertiesFile))
211    {
212      loggingProperties.load(inputStream);
213    }
214    catch (final Exception e)
215    {
216      Debug.debugException(e);
217      printError(
218           ERR_TOOL_LOGGER_ERROR_LOADING_PROPERTIES_FILE.get(
219                invocationLoggingPropertiesFile.getAbsolutePath(),
220                StaticUtils.getExceptionMessage(e)),
221           toolErrorStream);
222      return ToolInvocationLogDetails.createDoNotLogDetails(commandName);
223    }
224
225
226    // See if there is a tool-specific property that indicates whether to
227    // perform invocation logging for the tool.
228    Boolean logInvocation = getBooleanProperty(
229         commandName + ".log-tool-invocations", loggingProperties,
230         invocationLoggingPropertiesFile, null, toolErrorStream);
231
232
233    // If there wasn't a valid tool-specific property to indicate whether to
234    // perform invocation logging, then see if there is a default property for
235    // all tools.
236    if (logInvocation == null)
237    {
238      logInvocation = getBooleanProperty("default.log-tool-invocations",
239           loggingProperties, invocationLoggingPropertiesFile, null,
240           toolErrorStream);
241    }
242
243
244    // If we still don't know whether to log the invocation, then use the
245    // default setting for the tool.
246    if (logInvocation == null)
247    {
248      logInvocation = logByDefault;
249    }
250
251
252    // If we shouldn't log the invocation, then return a "no log" result now.
253    if (!logInvocation)
254    {
255      return ToolInvocationLogDetails.createDoNotLogDetails(commandName);
256    }
257
258
259    // See if there is a tool-specific property that specifies a log file path.
260    final Set<File> logFiles = new HashSet<>(StaticUtils.computeMapCapacity(2));
261    final String toolSpecificLogFilePathPropertyName =
262         commandName + ".log-file-path";
263    final File toolSpecificLogFile = getLogFileProperty(
264         toolSpecificLogFilePathPropertyName, loggingProperties,
265         invocationLoggingPropertiesFile, instanceRootDirectory,
266         toolErrorStream);
267    if (toolSpecificLogFile != null)
268    {
269      logFiles.add(toolSpecificLogFile);
270    }
271
272
273    // See if the tool should be included in the default log file.
274    if (getBooleanProperty(commandName + ".include-in-default-log",
275         loggingProperties, invocationLoggingPropertiesFile, true,
276         toolErrorStream))
277    {
278      // See if there is a property that specifies a default log file path.
279      // Otherwise, try to use the default path that we constructed earlier.
280      final String defaultLogFilePathPropertyName = "default.log-file-path";
281      final File defaultLogFile = getLogFileProperty(
282           defaultLogFilePathPropertyName, loggingProperties,
283           invocationLoggingPropertiesFile, instanceRootDirectory,
284           toolErrorStream);
285      if (defaultLogFile != null)
286      {
287        logFiles.add(defaultLogFile);
288      }
289      else if (canUseDefaultLog)
290      {
291        logFiles.add(defaultToolInvocationLogFile);
292      }
293      else
294      {
295        printError(
296             ERR_TOOL_LOGGER_NO_LOG_FILES.get(commandName,
297                  invocationLoggingPropertiesFile.getAbsolutePath(),
298                  toolSpecificLogFilePathPropertyName,
299                  defaultLogFilePathPropertyName),
300             toolErrorStream);
301      }
302    }
303
304
305    // If the set of log files is empty, then don't log anything.  Otherwise, we
306    // can and should perform invocation logging.
307    if (logFiles.isEmpty())
308    {
309      return ToolInvocationLogDetails.createDoNotLogDetails(commandName);
310    }
311    else
312    {
313      return ToolInvocationLogDetails.createLogDetails(commandName, null,
314           logFiles, toolErrorStream);
315    }
316  }
317
318
319
320  /**
321   * Retrieves the Boolean value of the specified property from the set of tool
322   * properties.
323   *
324   * @param  propertyName        The name of the property to retrieve.
325   * @param  properties          The set of tool properties.
326   * @param  propertiesFilePath  The path to the properties file.
327   * @param  defaultValue        The default value that should be returned if
328   *                             the property isn't set or has an invalid value.
329   * @param  toolErrorStream     A print stream that may be used to report
330   *                             information about any problems encountered
331   *                             while attempting to perform invocation logging.
332   *                             It must not be {@code null}.
333   *
334   * @return  {@code true} if the specified property exists with a value of
335   *          {@code true}, {@code false} if the specified property exists with
336   *          a value of {@code false}, or the default value if the property
337   *          doesn't exist or has a value that is neither {@code true} nor
338   *          {@code false}.
339   */
340  @Nullable()
341   private static Boolean getBooleanProperty(
342                @NotNull final String propertyName,
343                @NotNull final Properties properties,
344                @NotNull final File propertiesFilePath,
345                @Nullable final Boolean defaultValue,
346                @NotNull final PrintStream toolErrorStream)
347   {
348     final String propertyValue = properties.getProperty(propertyName);
349     if (propertyValue == null)
350     {
351       return defaultValue;
352     }
353
354     if (propertyValue.equalsIgnoreCase("true"))
355     {
356       return true;
357     }
358     else if (propertyValue.equalsIgnoreCase("false"))
359     {
360       return false;
361     }
362     else
363     {
364      printError(
365           ERR_TOOL_LOGGER_CANNOT_PARSE_BOOLEAN_PROPERTY.get(propertyValue,
366                propertyName, propertiesFilePath.getAbsolutePath()),
367           toolErrorStream);
368       return defaultValue;
369     }
370   }
371
372
373
374  /**
375   * Retrieves a file referenced by the specified property from the set of
376   * tool properties.
377   *
378   * @param  propertyName           The name of the property to retrieve.
379   * @param  properties             The set of tool properties.
380   * @param  propertiesFilePath     The path to the properties file.
381   * @param  instanceRootDirectory  The path to the server's instance root
382   *                                directory.
383   * @param  toolErrorStream        A print stream that may be used to report
384   *                                information about any problems encountered
385   *                                while attempting to perform invocation
386   *                                logging.  It must not be {@code null}.
387   *
388   * @return  A file referenced by the specified property, or {@code null} if
389   *          the property is not set or does not reference a valid path.
390   */
391  @Nullable()
392  private static File getLogFileProperty(
393               @NotNull final String propertyName,
394               @NotNull final Properties properties,
395               @NotNull final File propertiesFilePath,
396               @Nullable final File instanceRootDirectory,
397               @NotNull final PrintStream toolErrorStream)
398  {
399    final String propertyValue = properties.getProperty(propertyName);
400    if (propertyValue == null)
401    {
402      return null;
403    }
404
405    final File absoluteFile;
406    final File configuredFile = new File(propertyValue);
407    if (configuredFile.isAbsolute())
408    {
409      absoluteFile = configuredFile;
410    }
411    else
412    {
413      absoluteFile = new File(instanceRootDirectory.getAbsolutePath() +
414           File.separator + propertyValue);
415    }
416
417    if (absoluteFile.exists())
418    {
419      if (absoluteFile.isFile())
420      {
421        return absoluteFile;
422      }
423      else
424      {
425        printError(
426             ERR_TOOL_LOGGER_PATH_NOT_FILE.get(propertyValue, propertyName,
427                  propertiesFilePath.getAbsolutePath()),
428             toolErrorStream);
429      }
430    }
431    else
432    {
433      final File parentFile = absoluteFile.getParentFile();
434      if (parentFile.exists() && parentFile.isDirectory())
435      {
436        return absoluteFile;
437      }
438      else
439      {
440        printError(
441             ERR_TOOL_LOGGER_PATH_PARENT_MISSING.get(propertyValue,
442                  propertyName, propertiesFilePath.getAbsolutePath(),
443                  parentFile.getAbsolutePath()),
444             toolErrorStream);
445      }
446    }
447
448    return null;
449  }
450
451
452
453  /**
454   * Logs a message about the launch of the specified tool.  This method must
455   * acquire an exclusive lock on each log file before attempting to append any
456   * data to it.
457   *
458   * @param  logDetails               The tool invocation log details object
459   *                                  obtained from running the
460   *                                  {@link #getLogMessageDetails} method.  It
461   *                                  must not be {@code null}.
462   * @param  commandLineArguments     A list of the name-value pairs for any
463   *                                  command-line arguments provided when
464   *                                  running the program.  This must not be
465   *                                  {@code null}, but it may be empty.
466   *                                  <BR><BR>
467   *                                  For a tool run in interactive mode, this
468   *                                  should be the arguments that would have
469   *                                  been provided if the tool had been invoked
470   *                                  non-interactively.  For any arguments that
471   *                                  have a name but no value (including
472   *                                  Boolean arguments and subcommand names),
473   *                                  or for unnamed trailing arguments, the
474   *                                  first item in the pair should be
475   *                                  non-{@code null} and the second item
476   *                                  should be {@code null}.  For arguments
477   *                                  whose values may contain sensitive
478   *                                  information, the value should have already
479   *                                  been replaced with the string
480   *                                  "*****REDACTED*****".
481   * @param  propertiesFileArguments  A list of the name-value pairs for any
482   *                                  arguments obtained from a properties file
483   *                                  rather than being supplied on the command
484   *                                  line.  This must not be {@code null}, but
485   *                                  may be empty.  The same constraints
486   *                                  specified for the
487   *                                  {@code commandLineArguments} parameter
488   *                                  also apply to this parameter.
489   * @param  propertiesFilePath       The path to the properties file from which
490   *                                  the {@code propertiesFileArguments} values
491   *                                  were obtained.
492   */
493  public static void logLaunchMessage(
494          @NotNull final ToolInvocationLogDetails logDetails,
495          @NotNull final List<ObjectPair<String,String>> commandLineArguments,
496          @NotNull final List<ObjectPair<String,String>>
497               propertiesFileArguments,
498          @NotNull final String propertiesFilePath)
499  {
500    // Build the log message.
501    final StringBuilder msgBuffer = new StringBuilder();
502    final SimpleDateFormat dateFormat =
503         new SimpleDateFormat(LOG_MESSAGE_DATE_FORMAT);
504
505    msgBuffer.append("# [");
506    msgBuffer.append(dateFormat.format(new Date()));
507    msgBuffer.append(']');
508    msgBuffer.append(StaticUtils.EOL);
509    msgBuffer.append("# Command Name: ");
510    msgBuffer.append(logDetails.getCommandName());
511    msgBuffer.append(StaticUtils.EOL);
512    msgBuffer.append("# Invocation ID: ");
513    msgBuffer.append(logDetails.getInvocationID());
514    msgBuffer.append(StaticUtils.EOL);
515
516    final String systemUserName = StaticUtils.getSystemProperty("user.name");
517    if ((systemUserName != null) && (! systemUserName.isEmpty()))
518    {
519      msgBuffer.append("# System User: ");
520      msgBuffer.append(systemUserName);
521      msgBuffer.append(StaticUtils.EOL);
522    }
523
524    if (! propertiesFileArguments.isEmpty())
525    {
526      msgBuffer.append("# Arguments obtained from '");
527      msgBuffer.append(propertiesFilePath);
528      msgBuffer.append("':");
529      msgBuffer.append(StaticUtils.EOL);
530
531      for (final ObjectPair<String,String> argPair : propertiesFileArguments)
532      {
533        msgBuffer.append("#      ");
534
535        final String name = argPair.getFirst();
536        if (name.startsWith("-"))
537        {
538          msgBuffer.append(name);
539        }
540        else
541        {
542          msgBuffer.append(StaticUtils.cleanExampleCommandLineArgument(name));
543        }
544
545        final String value = argPair.getSecond();
546        if (value != null)
547        {
548          msgBuffer.append(' ');
549          msgBuffer.append(getCleanArgumentValue(name, value));
550        }
551
552        msgBuffer.append(StaticUtils.EOL);
553      }
554    }
555
556    msgBuffer.append(logDetails.getCommandName());
557    for (final ObjectPair<String,String> argPair : commandLineArguments)
558    {
559      msgBuffer.append(' ');
560
561      final String name = argPair.getFirst();
562      if (name.startsWith("-"))
563      {
564        msgBuffer.append(name);
565      }
566      else
567      {
568        msgBuffer.append(StaticUtils.cleanExampleCommandLineArgument(name));
569      }
570
571      final String value = argPair.getSecond();
572      if (value != null)
573      {
574        msgBuffer.append(' ');
575        msgBuffer.append(getCleanArgumentValue(name, value));
576      }
577    }
578    msgBuffer.append(StaticUtils.EOL);
579    msgBuffer.append(StaticUtils.EOL);
580
581    final byte[] logMessageBytes = StaticUtils.getBytes(msgBuffer.toString());
582
583
584    // Append the log message to each of the log files.
585    for (final File logFile : logDetails.getLogFiles())
586    {
587      logMessageToFile(logMessageBytes, logFile,
588           logDetails.getToolErrorStream());
589    }
590  }
591
592
593
594  /**
595   * Retrieves a cleaned and possibly redacted version of the provided argument
596   * value.
597   *
598   * @param  name   The name for the argument.  It must not be {@code null}.
599   * @param  value  The value for the argument.  It must not be {@code null}.
600   *
601   * @return  A cleaned and possibly redacted version of the provided argument
602   *          value.
603   */
604  @NotNull()
605  private static String getCleanArgumentValue(@NotNull final String name,
606                                              @NotNull final String value)
607  {
608    final String lowerName = StaticUtils.toLowerCase(name);
609    if (lowerName.contains("password") ||
610       lowerName.contains("passphrase") ||
611       lowerName.endsWith("-pin") ||
612       name.endsWith("Pin") ||
613       name.endsWith("PIN"))
614    {
615      if (! (lowerName.contains("passwordfile") ||
616           lowerName.contains("password-file") ||
617           lowerName.contains("passwordpath") ||
618           lowerName.contains("password-path") ||
619           lowerName.contains("passphrasefile") ||
620           lowerName.contains("passphrase-file") ||
621           lowerName.contains("passphrasepath") ||
622           lowerName.contains("passphrase-path")))
623      {
624        if (! StaticUtils.toLowerCase(value).contains("redacted"))
625        {
626          return StaticUtils.cleanExampleCommandLineArgument(
627               "*****REDACTED*****");
628        }
629      }
630    }
631
632    return StaticUtils.cleanExampleCommandLineArgument(value);
633  }
634
635
636
637  /**
638   * Logs a message about the completion of the specified tool.  This method
639   * must acquire an exclusive lock on each log file before attempting to append
640   * any data to it.
641   *
642   * @param  logDetails   The tool invocation log details object obtained from
643   *                      running the {@link #getLogMessageDetails} method.  It
644   *                      must not be {@code null}.
645   * @param  exitCode     An integer exit code that may be used to broadly
646   *                      indicate whether the tool completed successfully.  A
647   *                      value of zero typically indicates that it did
648   *                      complete successfully, while a nonzero value generally
649   *                      indicates that some error occurred.  This may be
650   *                      {@code null} if the tool did not complete normally
651   *                      (for example, because the tool processing was
652   *                      interrupted by a JVM shutdown).
653   * @param  exitMessage  An optional message that provides information about
654   *                      the completion of the tool processing.  It may be
655   *                      {@code null} if no such message is available.
656   */
657  public static void logCompletionMessage(
658                          @NotNull final ToolInvocationLogDetails logDetails,
659                          @Nullable final Integer exitCode,
660                          @Nullable final String exitMessage)
661  {
662    // Build the log message.
663    final StringBuilder msgBuffer = new StringBuilder();
664    final SimpleDateFormat dateFormat =
665         new SimpleDateFormat(LOG_MESSAGE_DATE_FORMAT);
666
667    msgBuffer.append("# [");
668    msgBuffer.append(dateFormat.format(new Date()));
669    msgBuffer.append(']');
670    msgBuffer.append(StaticUtils.EOL);
671    msgBuffer.append("# Command Name: ");
672    msgBuffer.append(logDetails.getCommandName());
673    msgBuffer.append(StaticUtils.EOL);
674    msgBuffer.append("# Invocation ID: ");
675    msgBuffer.append(logDetails.getInvocationID());
676    msgBuffer.append(StaticUtils.EOL);
677
678    if (exitCode != null)
679    {
680      msgBuffer.append("# Exit Code: ");
681      msgBuffer.append(exitCode);
682      msgBuffer.append(StaticUtils.EOL);
683    }
684
685    if (exitMessage != null)
686    {
687      msgBuffer.append("# Exit Message: ");
688      cleanMessage(exitMessage, msgBuffer);
689      msgBuffer.append(StaticUtils.EOL);
690    }
691
692    msgBuffer.append(StaticUtils.EOL);
693
694    final byte[] logMessageBytes = StaticUtils.getBytes(msgBuffer.toString());
695
696
697    // Append the log message to each of the log files.
698    for (final File logFile : logDetails.getLogFiles())
699    {
700      logMessageToFile(logMessageBytes, logFile,
701           logDetails.getToolErrorStream());
702    }
703  }
704
705
706
707  /**
708   * Writes a clean representation of the provided message to the given buffer.
709   * All ASCII characters from the space to the tilde will be preserved.  All
710   * other characters will use the hexadecimal representation of the bytes that
711   * make up that character, with each pair of hexadecimal digits escaped with a
712   * backslash.
713   *
714   * @param  message  The message to be cleaned.
715   * @param  buffer   The buffer to which the message should be appended.
716   */
717  private static void cleanMessage(@NotNull final String message,
718                                   @NotNull final StringBuilder buffer)
719  {
720    for (final char c : message.toCharArray())
721    {
722      if ((c >= ' ') && (c <= '~'))
723      {
724        buffer.append(c);
725      }
726      else
727      {
728        for (final byte b : StaticUtils.getBytes(Character.toString(c)))
729        {
730          buffer.append('\\');
731          StaticUtils.toHex(b, buffer);
732        }
733      }
734    }
735  }
736
737
738
739  /**
740   * Acquires an exclusive lock on the specified log file and appends the
741   * provided log message to it.
742   *
743   * @param  logMessageBytes  The bytes that comprise the log message to be
744   *                          appended to the log file.
745   * @param  logFile          The log file to be locked and updated.
746   * @param  toolErrorStream  A print stream that may be used to report
747   *                          information about any problems encountered while
748   *                          attempting to perform invocation logging.  It
749   *                          must not be {@code null}.
750   */
751  private static void logMessageToFile(@NotNull final byte[] logMessageBytes,
752               @NotNull final File logFile,
753               @NotNull final PrintStream toolErrorStream)
754  {
755    // Open a file channel for the target log file.
756    final Set<StandardOpenOption> openOptionsSet = EnumSet.of(
757            StandardOpenOption.CREATE, // Create the file if it doesn't exist.
758            StandardOpenOption.APPEND, // Append to file if it already exists.
759            StandardOpenOption.DSYNC); // Synchronously flush file on writing.
760
761    final FileAttribute<?>[] fileAttributes;
762    if (StaticUtils.isWindows())
763    {
764      fileAttributes = new FileAttribute<?>[0];
765    }
766    else
767    {
768      final Set<PosixFilePermission> filePermissionsSet = EnumSet.of(
769              PosixFilePermission.OWNER_READ,   // Grant owner read access.
770              PosixFilePermission.OWNER_WRITE); // Grant owner write access.
771      final FileAttribute<Set<PosixFilePermission>> filePermissionsAttribute =
772              PosixFilePermissions.asFileAttribute(filePermissionsSet);
773      fileAttributes = new FileAttribute<?>[] { filePermissionsAttribute };
774    }
775
776    try (FileChannel fileChannel =
777              FileChannel.open(logFile.toPath(), openOptionsSet,
778                   fileAttributes))
779    {
780      try (FileLock fileLock =
781                acquireFileLock(fileChannel, logFile, toolErrorStream))
782      {
783        if (fileLock != null)
784        {
785          try
786          {
787            fileChannel.write(ByteBuffer.wrap(logMessageBytes));
788          }
789          catch (final Exception e)
790          {
791            Debug.debugException(e);
792            printError(
793                 ERR_TOOL_LOGGER_ERROR_WRITING_LOG_MESSAGE.get(
794                      logFile.getAbsolutePath(),
795                      StaticUtils.getExceptionMessage(e)),
796                 toolErrorStream);
797          }
798        }
799      }
800    }
801    catch (final Exception e)
802    {
803      Debug.debugException(e);
804      printError(
805           ERR_TOOL_LOGGER_ERROR_OPENING_LOG_FILE.get(logFile.getAbsolutePath(),
806                StaticUtils.getExceptionMessage(e)),
807           toolErrorStream);
808    }
809  }
810
811
812
813  /**
814   * Attempts to acquire an exclusive file lock on the provided file channel.
815   *
816   * @param  fileChannel      The file channel on which to acquire the file
817   *                          lock.
818   * @param  logFile          The path to the log file being locked.
819   * @param  toolErrorStream  A print stream that may be used to report
820   *                          information about any problems encountered while
821   *                          attempting to perform invocation logging.  It
822   *                          must not be {@code null}.
823   *
824   * @return  The file lock that was acquired, or {@code null} if the lock could
825   *          not be acquired.
826   */
827  @Nullable()
828  private static FileLock acquireFileLock(
829               @NotNull final FileChannel fileChannel,
830               @NotNull final File logFile,
831               @NotNull final PrintStream toolErrorStream)
832  {
833    try
834    {
835      final FileLock fileLock = fileChannel.tryLock();
836      if (fileLock != null)
837      {
838        return fileLock;
839      }
840    }
841    catch (final Exception e)
842    {
843      Debug.debugException(e);
844    }
845
846    int numAttempts = 1;
847    final long stopWaitingTime = System.currentTimeMillis() + 1000L;
848    while (System.currentTimeMillis() <= stopWaitingTime)
849    {
850      try
851      {
852        Thread.sleep(10L);
853        final FileLock fileLock = fileChannel.tryLock();
854        if (fileLock != null)
855        {
856          return fileLock;
857        }
858      }
859      catch (final Exception e)
860      {
861        Debug.debugException(e);
862      }
863
864      numAttempts++;
865    }
866
867    printError(
868         ERR_TOOL_LOGGER_UNABLE_TO_ACQUIRE_FILE_LOCK.get(
869              logFile.getAbsolutePath(), numAttempts),
870         toolErrorStream);
871    return null;
872  }
873
874
875
876  /**
877   * Prints the provided message using the tool output stream.  The message will
878   * be wrapped across multiple lines if necessary, and each line will be
879   * prefixed with the octothorpe character (#) so that it is likely to be
880   * interpreted as a comment by anything that tries to parse the tool output.
881   *
882   * @param  message          The message to be written.
883   * @param  toolErrorStream  The print stream that should be used to write the
884   *                          message.
885   */
886  private static void printError(@NotNull final String message,
887                                 @NotNull final PrintStream toolErrorStream)
888  {
889    toolErrorStream.println();
890
891    final int maxWidth = StaticUtils.TERMINAL_WIDTH_COLUMNS - 3;
892    for (final String line : StaticUtils.wrapLine(message, maxWidth))
893    {
894      toolErrorStream.println("# " + line);
895    }
896  }
897}