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
This commit is contained in:
Magnus Bäck
2012-03-22 09:55:03 -04:00
parent 4a80e99da5
commit 21f533aec4
6 changed files with 335 additions and 3 deletions

View File

@@ -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::

View File

@@ -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();
}
}

View File

@@ -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;
}
}
}

View File

@@ -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');
}

View File

@@ -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]);
}
}
}

View File

@@ -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");
}
}