From 21f533aec48d5449065b36e916cd3d03f8797cba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20B=C3=A4ck?= Date: Thu, 22 Mar 2012 09:55:03 -0400 Subject: [PATCH] Add ColumnFormatter/StringUtil classes for text output The ColumnFormatter class will simplify output of column-oriented text for SSH commands like ls-groups and others (including HTTP counterparts) that may print user-supplied text that can contain tab and newline characters that would screw up scripts using these commands for automation. To deal with this, all text output is subject to C-style escaping of all non-printable characters. The escaping in done in the new StringUtil class. So far this new functionality is only used by the ls-projects command when the project description is printed. The escaping method used there previously only took care of newline characters. Change-Id: Ia605bbc44fc82d69d2f40b337319368aefd01eee --- Documentation/cmd-ls-projects.txt | 7 +- .../com/google/gerrit/server/StringUtil.java | 54 +++++++ .../gerrit/server/ioutil/ColumnFormatter.java | 82 +++++++++++ .../gerrit/server/project/ListProjects.java | 3 +- .../google/gerrit/server/StringUtilTest.java | 54 +++++++ .../server/ioutil/ColumnFormatterTest.java | 138 ++++++++++++++++++ 6 files changed, 335 insertions(+), 3 deletions(-) create mode 100644 gerrit-server/src/main/java/com/google/gerrit/server/StringUtil.java create mode 100644 gerrit-server/src/main/java/com/google/gerrit/server/ioutil/ColumnFormatter.java create mode 100644 gerrit-server/src/test/java/com/google/gerrit/server/StringUtilTest.java create mode 100644 gerrit-server/src/test/java/com/google/gerrit/server/ioutil/ColumnFormatterTest.java diff --git a/Documentation/cmd-ls-projects.txt b/Documentation/cmd-ls-projects.txt index c1b37fa90c..25cd9a9102 100644 --- a/Documentation/cmd-ls-projects.txt +++ b/Documentation/cmd-ls-projects.txt @@ -46,8 +46,11 @@ OPTIONS Allows listing of projects together with their respective description. + -Line-feeds are escaped to allow ls-project to keep the -"one project per line"-style. +For text format output, all non-printable characters (ASCII value 31 or +less) are escaped according to the conventions used in languages like C, +Python, and Perl, employing standard sequences like `\n` and `\t`, and +`\xNN` for all others. In shell scripts, the `printf` command can be +used to unescape the output. --tree:: -t:: diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/StringUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/StringUtil.java new file mode 100644 index 0000000000..fe1072d36d --- /dev/null +++ b/gerrit-server/src/main/java/com/google/gerrit/server/StringUtil.java @@ -0,0 +1,54 @@ +// Copyright (C) 2012 The Android Open Source Project +// +// Licensed 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 com.google.gerrit.server; + +public class StringUtil { + /** + * An array of the string representations that should be used in place + * of the non-printable characters in the beginning of the ASCII table + * when escaping a string. The index of each element in the array + * corresponds to its ASCII value, i.e. the string representation of + * ASCII 0 is found in the first element of this array. + */ + static String[] NON_PRINTABLE_CHARS = + { "\\x00", "\\x01", "\\x02", "\\x03", "\\x04", "\\x05", "\\x06", "\\a", + "\\b", "\\t", "\\n", "\\v", "\\f", "\\r", "\\x0e", "\\x0f", + "\\x10", "\\x11", "\\x12", "\\x13", "\\x14", "\\x15", "\\x16", "\\x17", + "\\x18", "\\x19", "\\x1a", "\\x1b", "\\x1c", "\\x1d", "\\x1e", "\\x1f" }; + + /** + * Escapes the input string so that all non-printable characters + * (0x00-0x1f) are represented as a hex escape (\x00, \x01, ...) + * or as a C-style escape sequence (\a, \b, \t, \n, \v, \f, or \r). + * Backslashes in the input string are doubled (\\). + */ + public static String escapeString(final String str) { + // Allocate a buffer big enough to cover the case with a string needed + // very excessive escaping without having to reallocate the buffer. + final StringBuilder result = new StringBuilder(3 * str.length()); + + for (int i = 0; i < str.length(); i++) { + char c = str.charAt(i); + if (c < NON_PRINTABLE_CHARS.length) { + result.append(NON_PRINTABLE_CHARS[c]); + } else if (c == '\\') { + result.append("\\\\"); + } else { + result.append(c); + } + } + return result.toString(); + } +} diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ioutil/ColumnFormatter.java b/gerrit-server/src/main/java/com/google/gerrit/server/ioutil/ColumnFormatter.java new file mode 100644 index 0000000000..a73f1cb90f --- /dev/null +++ b/gerrit-server/src/main/java/com/google/gerrit/server/ioutil/ColumnFormatter.java @@ -0,0 +1,82 @@ +// Copyright (C) 2012 The Android Open Source Project +// +// Licensed 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 com.google.gerrit.server.ioutil; + +import com.google.gerrit.server.StringUtil; + +import java.io.PrintWriter; + +/** + * Simple output formatter for column-oriented data, writing its output to + * a {@link java.io.PrintWriter} object. Handles escaping of the column + * data so that the resulting output is unambiguous and reasonably safe and + * machine parsable. + */ +public class ColumnFormatter { + private char columnSeparator; + private boolean firstColumn; + private final PrintWriter out; + + /** + * @param out The writer to which output should be sent. + * @param columnSeparator A character that should serve as the separator + * token between columns of output. As only non-printable characters + * in the column text are ever escaped, the column separator must be + * a non-printable character if the output needs to be unambiguously + * parsed. + */ + public ColumnFormatter(final PrintWriter out, final char columnSeparator) { + this.out = out; + this.columnSeparator = columnSeparator; + this.firstColumn = true; + } + + /** + * Adds a text string as a new column in the current line of output, + * taking care of escaping as necessary. + * + * @param content the string to add. + */ + public void addColumn(final String content) { + if (!firstColumn) { + out.print(columnSeparator); + } + out.print(StringUtil.escapeString(content)); + firstColumn = false; + } + + /** + * Finishes the output by flushing the current line and takes care of any + * other cleanup action. + */ + public void finish() { + nextLine(); + out.flush(); + } + + /** + * Flushes the current line of output and makes the formatter ready to + * start receiving new column data for a new line (or end-of-file). + * If the current line is empty nothing is done, i.e. consecutive calls + * to this method without intervening calls to {@link #addColumn} will + * be squashed. + */ + public void nextLine() { + if (!firstColumn) { + out.print('\n'); + firstColumn = true; + } + } +} diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java index 716a5a82f8..8b4e000cc8 100644 --- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java +++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java @@ -19,6 +19,7 @@ import com.google.gerrit.reviewdb.client.Project; import com.google.gerrit.reviewdb.client.Project.NameKey; import com.google.gerrit.server.CurrentUser; import com.google.gerrit.server.OutputFormat; +import com.google.gerrit.server.StringUtil; import com.google.gerrit.server.git.GitRepositoryManager; import com.google.gerrit.server.util.TreeFormatter; import com.google.gson.reflect.TypeToken; @@ -269,7 +270,7 @@ public class ListProjects { if (info.description != null) { // We still want to list every project as one-liners, hence escaping \n. - stdout.print(" - " + info.description.replace("\n", "\\n")); + stdout.print(" - " + StringUtil.escapeString(info.description)); } stdout.print('\n'); } diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/StringUtilTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/StringUtilTest.java new file mode 100644 index 0000000000..24f3386212 --- /dev/null +++ b/gerrit-server/src/test/java/com/google/gerrit/server/StringUtilTest.java @@ -0,0 +1,54 @@ +// Copyright (C) 2012 The Android Open Source Project +// +// Licensed 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 com.google.gerrit.server; + +import junit.framework.TestCase; + +public class StringUtilTest extends TestCase { + /** + * Test the boundary condition that the first character of a string + * should be escaped. + */ + public void testEscapeFirstChar() { + assertEquals(StringUtil.escapeString("\tLeading tab"), "\\tLeading tab"); + } + + /** + * Test the boundary condition that the last character of a string + * should be escaped. + */ + public void testEscapeLastChar() { + assertEquals(StringUtil.escapeString("Trailing tab\t"), "Trailing tab\\t"); + } + + /** + * Test that various forms of input strings are escaped (or left as-is) + * in the expected way. + */ + public void testEscapeString() { + final String[] testPairs = + { "", "", + "plain string", "plain string", + "string with \"quotes\"", "string with \"quotes\"", + "string with 'quotes'", "string with 'quotes'", + "string with 'quotes'", "string with 'quotes'", + "C:\\Program Files\\MyProgram", "C:\\\\Program Files\\\\MyProgram", + "string\nwith\nnewlines", "string\\nwith\\nnewlines", + "string\twith\ttabs", "string\\twith\\ttabs" }; + for (int i = 0; i < testPairs.length; i += 2) { + assertEquals(StringUtil.escapeString(testPairs[i]), testPairs[i + 1]); + } + } +} diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/ioutil/ColumnFormatterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/ioutil/ColumnFormatterTest.java new file mode 100644 index 0000000000..2d432e635f --- /dev/null +++ b/gerrit-server/src/test/java/com/google/gerrit/server/ioutil/ColumnFormatterTest.java @@ -0,0 +1,138 @@ +// Copyright (C) 2012 The Android Open Source Project +// +// Licensed 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 com.google.gerrit.server.ioutil; + +import com.google.gerrit.server.ioutil.ColumnFormatter; + +import junit.framework.TestCase; + +import java.io.PrintWriter; +import java.io.StringWriter; + +public class ColumnFormatterTest extends TestCase { + /** + * Holds an in-memory {@link java.io.PrintWriter} object and allows + * comparisons of its contents to a supplied string via an assert statement. + */ + class PrintWriterComparator { + private PrintWriter printWriter; + private StringWriter stringWriter; + + public PrintWriterComparator() { + stringWriter = new StringWriter(); + printWriter = new PrintWriter(stringWriter); + } + + public void assertEquals(String str) { + printWriter.flush(); + TestCase.assertEquals(stringWriter.toString(), str); + } + + public PrintWriter getPrintWriter() { + return printWriter; + } + } + + /** + * Test that only lines with at least one column of text emit output. + */ + public void testEmptyLine() { + final PrintWriterComparator comparator = new PrintWriterComparator(); + final ColumnFormatter formatter = + new ColumnFormatter(comparator.getPrintWriter(), '\t'); + formatter.addColumn("foo"); + formatter.addColumn("bar"); + formatter.nextLine(); + formatter.nextLine(); + formatter.nextLine(); + formatter.addColumn("foo"); + formatter.addColumn("bar"); + formatter.finish(); + comparator.assertEquals("foo\tbar\nfoo\tbar\n"); + } + + /** + * Test that there is no output if no columns are ever added. + */ + public void testEmptyOutput() { + final PrintWriterComparator comparator = new PrintWriterComparator(); + final ColumnFormatter formatter = + new ColumnFormatter(comparator.getPrintWriter(), '\t'); + formatter.nextLine(); + formatter.nextLine(); + formatter.finish(); + comparator.assertEquals(""); + } + + /** + * Test that there is no output (nor any exceptions) if we finalize + * the output immediately after the creation of the {@link ColumnFormatter}. + */ + public void testNoNextLine() { + final PrintWriterComparator comparator = new PrintWriterComparator(); + final ColumnFormatter formatter = + new ColumnFormatter(comparator.getPrintWriter(), '\t'); + formatter.finish(); + comparator.assertEquals(""); + } + + /** + * Test that the text in added columns is escaped while the column separator + * (which of course shouldn't be escaped) is left alone. + */ + public void testEscapingTakesPlace() { + final PrintWriterComparator comparator = new PrintWriterComparator(); + final ColumnFormatter formatter = + new ColumnFormatter(comparator.getPrintWriter(), '\t'); + formatter.addColumn("foo"); + formatter.addColumn( + "\tan indented multi-line\ntext"); + formatter.nextLine(); + formatter.finish(); + comparator.assertEquals("foo\t\\tan indented multi-line\\ntext\n"); + } + + /** + * Test that we get the correct output with multi-line input where the number + * of columns in each line varies. + */ + public void testMultiLineDifferentColumnCount() { + final PrintWriterComparator comparator = new PrintWriterComparator(); + final ColumnFormatter formatter = + new ColumnFormatter(comparator.getPrintWriter(), '\t'); + formatter.addColumn("foo"); + formatter.addColumn("bar"); + formatter.addColumn("baz"); + formatter.nextLine(); + formatter.addColumn("foo"); + formatter.addColumn("bar"); + formatter.nextLine(); + formatter.finish(); + comparator.assertEquals("foo\tbar\tbaz\nfoo\tbar\n"); + } + + /** + * Test that we get the correct output with a single column of input. + */ + public void testOneColumn() { + final PrintWriterComparator comparator = new PrintWriterComparator(); + final ColumnFormatter formatter = + new ColumnFormatter(comparator.getPrintWriter(), '\t'); + formatter.addColumn("foo"); + formatter.nextLine(); + formatter.finish(); + comparator.assertEquals("foo\n"); + } +}