diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/transport/PushCertificateIdentTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/transport/PushCertificateIdentTest.java new file mode 100644 index 000000000..68aff72df --- /dev/null +++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/transport/PushCertificateIdentTest.java @@ -0,0 +1,196 @@ +/* + * Copyright (C) 2015, Google Inc. + * and other copyright owners as documented in the project's IP log. + * + * This program and the accompanying materials are made available + * under the terms of the Eclipse Distribution License v1.0 which + * accompanies this distribution, is reproduced below, and is + * available at http://www.eclipse.org/org/documents/edl-v10.php + * + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or + * without modification, are permitted provided that the following + * conditions are met: + * + * - Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * - Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following + * disclaimer in the documentation and/or other materials provided + * with the distribution. + * + * - Neither the name of the Eclipse Foundation, Inc. nor the + * names of its contributors may be used to endorse or promote + * products derived from this software without specific prior + * written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT + * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF + * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package org.eclipse.jgit.transport; + +import static org.eclipse.jgit.transport.PushCertificateIdent.parse; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import java.util.Date; +import java.util.TimeZone; + +import org.eclipse.jgit.lib.PersonIdent; +import org.junit.Test; + +public class PushCertificateIdentTest { + @Test + public void parseValid() throws Exception { + String raw = "A U. Thor 1218123387 +0700"; + PushCertificateIdent ident = parse(raw); + assertEquals(raw, ident.getRaw()); + assertEquals("A U. Thor ", ident.getUserId()); + assertEquals("A U. Thor", ident.getName()); + assertEquals("a_u_thor@example.com", ident.getEmailAddress()); + assertEquals(1218123387000L, ident.getWhen().getTime()); + assertEquals(TimeZone.getTimeZone("GMT+0700"), ident.getTimeZone()); + assertEquals(7 * 60, ident.getTimeZoneOffset()); + } + + @Test + public void trimName() throws Exception { + String name = "A U. Thor"; + String email = "a_u_thor@example.com"; + String rest = " 1218123387 +0700"; + + checkNameEmail(name, email, name + rest); + checkNameEmail(name, email, " " + name + rest); + checkNameEmail(name, email, " " + name + rest); + checkNameEmail(name, email, name + " " + rest); + checkNameEmail(name, email, name + " " + rest); + checkNameEmail(name, email, " " + name + " " + rest); + } + + @Test + public void noEmail() throws Exception { + String name = "A U. Thor"; + String rest = " 1218123387 +0700"; + + checkNameEmail(name, null, name + rest); + checkNameEmail(name, null, " " + name + rest); + checkNameEmail(name, null, " " + name + rest); + checkNameEmail(name, null, name + " " + rest); + checkNameEmail(name, null, name + " " + rest); + checkNameEmail(name, null, " " + name + " " + rest); + } + + @Test + public void exoticUserId() throws Exception { + String rest = " 218123387 +0700"; + assertEquals("", parse(rest).getUserId()); + + String id = "foo\n\0bar\uabcd\n "; + assertEquals(id, parse(id + rest).getUserId()); + } + + @Test + public void fuzzyCasesMatchPersonIdent() throws Exception { + // See RawParseUtils_ParsePersonIdentTest#testParsePersonIdent_fuzzyCases() + Date when = new Date(1234567890000l); + TimeZone tz = TimeZone.getTimeZone("GMT-7"); + + assertMatchesPersonIdent( + "A U Thor , C O. Miter 1234567890 -0700", + new PersonIdent("A U Thor", "author@example.com", when, tz), + "A U Thor , C O. Miter "); + assertMatchesPersonIdent( + "A U Thor and others 1234567890 -0700", + new PersonIdent("A U Thor", "author@example.com", when, tz), + "A U Thor and others"); + } + + @Test + public void incompleteCasesMatchPersonIdent() throws Exception { + // See RawParseUtils_ParsePersonIdentTest#testParsePersonIdent_incompleteCases() + Date when = new Date(1234567890000l); + TimeZone tz = TimeZone.getTimeZone("GMT-7"); + + assertMatchesPersonIdent( + "Me <> 1234567890 -0700", + new PersonIdent("Me", "", when, tz), + "Me <>"); + assertMatchesPersonIdent( + " 1234567890 -0700", + new PersonIdent("", "me@example.com", when, tz), + " "); + assertMatchesPersonIdent( + " <> 1234567890 -0700", + new PersonIdent("", "", when, tz), + " <>"); + assertMatchesPersonIdent( + "<>", + new PersonIdent("", "", 0, 0), + "<>"); + assertMatchesPersonIdent( + " <>", + new PersonIdent("", "", 0, 0), + " <>"); + assertMatchesPersonIdent( + "", + new PersonIdent("", "me@example.com", 0, 0), + ""); + assertMatchesPersonIdent( + " ", + new PersonIdent("", "me@example.com", 0, 0), + " "); + assertMatchesPersonIdent( + "Me <>", + new PersonIdent("Me", "", 0, 0), + "Me <>"); + assertMatchesPersonIdent( + "Me ", + new PersonIdent("Me", "me@example.com", 0, 0), + "Me "); + assertMatchesPersonIdent( + "Me 1234567890", + new PersonIdent("Me", "me@example.com", 0, 0), + "Me "); + assertMatchesPersonIdent( + "Me 1234567890 ", + new PersonIdent("Me", "me@example.com", 0, 0), + "Me "); + } + + private static void assertMatchesPersonIdent(String raw, + PersonIdent expectedPersonIdent, String expectedUserId) { + PushCertificateIdent certIdent = PushCertificateIdent.parse(raw); + assertNotNull(raw); + assertEquals(raw, certIdent.getRaw()); + assertEquals(expectedPersonIdent.getName(), certIdent.getName()); + assertEquals(expectedPersonIdent.getEmailAddress(), + certIdent.getEmailAddress()); + assertEquals(expectedPersonIdent.getWhen(), certIdent.getWhen()); + assertEquals(expectedPersonIdent.getTimeZoneOffset(), + certIdent.getTimeZoneOffset()); + assertEquals(expectedUserId, certIdent.getUserId()); + } + + private static void checkNameEmail(String expectedName, String expectedEmail, + String raw) { + PushCertificateIdent ident = parse(raw); + assertNotNull(ident); + assertEquals(raw, ident.getRaw()); + assertEquals(expectedName, ident.getName()); + assertEquals(expectedEmail, ident.getEmailAddress()); + } +} diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/lib/PersonIdent.java b/org.eclipse.jgit/src/org/eclipse/jgit/lib/PersonIdent.java index e8591195c..2ecc60c3b 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/lib/PersonIdent.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/lib/PersonIdent.java @@ -63,6 +63,54 @@ public class PersonIdent implements Serializable { private static final long serialVersionUID = 1L; + /** + * @param tzOffset + * timezone offset as in {@link #getTimeZoneOffset()}. + * @return time zone object for the given offset. + * @since 4.1 + */ + public static TimeZone getTimeZone(int tzOffset) { + StringBuilder tzId = new StringBuilder(8); + tzId.append("GMT"); //$NON-NLS-1$ + appendTimezone(tzId, tzOffset); + return TimeZone.getTimeZone(tzId.toString()); + } + + /** + * Format a timezone offset. + * + * @param r + * string builder to append to. + * @param offset + * timezone offset as in {@link #getTimeZoneOffset()}. + * @since 4.1 + */ + public static void appendTimezone(StringBuilder r, int offset) { + final char sign; + final int offsetHours; + final int offsetMins; + + if (offset < 0) { + sign = '-'; + offset = -offset; + } else { + sign = '+'; + } + + offsetHours = offset / 60; + offsetMins = offset % 60; + + r.append(sign); + if (offsetHours < 10) { + r.append('0'); + } + r.append(offsetHours); + if (offsetMins < 10) { + r.append('0'); + } + r.append(offsetMins); + } + private final String name; private final String emailAddress; @@ -217,10 +265,7 @@ public Date getWhen() { * @return this person's declared time zone; null if time zone is unknown. */ public TimeZone getTimeZone() { - StringBuilder tzId = new StringBuilder(8); - tzId.append("GMT"); //$NON-NLS-1$ - appendTimezone(tzId); - return TimeZone.getTimeZone(tzId.toString()); + return getTimeZone(tzOffset); } /** @@ -261,37 +306,10 @@ public String toExternalString() { r.append("> "); //$NON-NLS-1$ r.append(when / 1000); r.append(' '); - appendTimezone(r); + appendTimezone(r, tzOffset); return r.toString(); } - private void appendTimezone(final StringBuilder r) { - int offset = tzOffset; - final char sign; - final int offsetHours; - final int offsetMins; - - if (offset < 0) { - sign = '-'; - offset = -offset; - } else { - sign = '+'; - } - - offsetHours = offset / 60; - offsetMins = offset % 60; - - r.append(sign); - if (offsetHours < 10) { - r.append('0'); - } - r.append(offsetHours); - if (offsetMins < 10) { - r.append('0'); - } - r.append(offsetMins); - } - @SuppressWarnings("nls") public String toString() { final StringBuilder r = new StringBuilder(); diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/transport/PushCertificate.java b/org.eclipse.jgit/src/org/eclipse/jgit/transport/PushCertificate.java index cf0db0e32..6dc4153d1 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/transport/PushCertificate.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/transport/PushCertificate.java @@ -52,7 +52,6 @@ import java.util.List; import org.eclipse.jgit.internal.JGitText; -import org.eclipse.jgit.lib.PersonIdent; /** * The required information to verify the push. @@ -81,7 +80,7 @@ public enum NonceStatus { } private final String version; - private final PersonIdent pusher; + private final PushCertificateIdent pusher; private final String pushee; private final String nonce; private final NonceStatus nonceStatus; @@ -89,7 +88,7 @@ public enum NonceStatus { private final String rawCommands; private final String signature; - PushCertificate(String version, PersonIdent pusher, String pushee, + PushCertificate(String version, PushCertificateIdent pusher, String pushee, String nonce, NonceStatus nonceStatus, List commands, String rawCommands, String signature) { if (version == null || version.isEmpty()) { @@ -147,18 +146,18 @@ public String getVersion() { } /** - * @return the identity of the pusher who signed the cert, as a string. + * @return the raw line that signed the cert, as a string. * @since 4.0 */ public String getPusher() { - return pusher.toExternalString(); + return pusher.getRaw(); } /** * @return identity of the pusher who signed the cert. * @since 4.1 */ - public PersonIdent getPusherIdent() { + public PushCertificateIdent getPusherIdent() { return pusher; } @@ -212,7 +211,7 @@ public String getSignature() { public String toText() { return new StringBuilder() .append(VERSION).append(' ').append(version).append('\n') - .append(PUSHER).append(' ').append(pusher.toExternalString()) + .append(PUSHER).append(' ').append(getPusher()) .append('\n') .append(PUSHEE).append(' ').append(pushee).append('\n') .append(NONCE).append(' ').append(nonce).append('\n') diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/transport/PushCertificateIdent.java b/org.eclipse.jgit/src/org/eclipse/jgit/transport/PushCertificateIdent.java new file mode 100644 index 000000000..c7618b0e6 --- /dev/null +++ b/org.eclipse.jgit/src/org/eclipse/jgit/transport/PushCertificateIdent.java @@ -0,0 +1,281 @@ +/* + * Copyright (C) 2015, Google Inc. + * and other copyright owners as documented in the project's IP log. + * + * This program and the accompanying materials are made available + * under the terms of the Eclipse Distribution License v1.0 which + * accompanies this distribution, is reproduced below, and is + * available at http://www.eclipse.org/org/documents/edl-v10.php + * + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or + * without modification, are permitted provided that the following + * conditions are met: + * + * - Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * - Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following + * disclaimer in the documentation and/or other materials provided + * with the distribution. + * + * - Neither the name of the Eclipse Foundation, Inc. nor the + * names of its contributors may be used to endorse or promote + * products derived from this software without specific prior + * written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT + * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF + * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package org.eclipse.jgit.transport; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import static org.eclipse.jgit.util.RawParseUtils.lastIndexOfTrim; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.TimeZone; + +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.util.MutableInteger; +import org.eclipse.jgit.util.RawParseUtils; + +/** + * Identity in a push certificate. + *

+ * This is similar to a {@link PersonIdent} in that it contains a name, + * timestamp, and timezone offset, but differs in the following ways: + *

    + *
  • It is always parsed from a UTF-8 string, rather than a raw commit + * buffer.
  • + *
  • It is not guaranteed to contain a name and email portion, since any UTF-8 + * string is a valid OpenPGP User ID (RFC4880 5.1.1). The raw User ID is + * always available as {@link #getUserId()}, but {@link #getEmailAddress()} + * may return null.
  • + *
  • The raw text from which the identity was parsed is available with {@link + * #getRaw()}. This is necessary for losslessly reconstructing the signed push + * certificate payload.
  • + *
  • + *
+ * + * @since 4.1 + */ +public class PushCertificateIdent { + /** + * Parse an identity from a string. + *

+ * Spaces are trimmed when parsing the timestamp and timezone offset, with one + * exception. The timestamp must be preceded by a single space, and the rest + * of the string prior to that space (including any additional whitespace) is + * treated as the OpenPGP User ID. + *

+ * If either the timestamp or timezone offsets are missing, mimics {@link + * RawParseUtils#parsePersonIdent(String)} behavior and sets them both to + * zero. + * + * @param str + * string to parse. + * @return identity, never null. + * @since 4.1 + */ + public static PushCertificateIdent parse(String str) { + MutableInteger p = new MutableInteger(); + byte[] raw = str.getBytes(UTF_8); + int tzBegin = raw.length - 1; + tzBegin = lastIndexOfTrim(raw, ' ', tzBegin); + if (tzBegin < 0 || raw[tzBegin] != ' ') { + return new PushCertificateIdent(str, str, 0, 0); + } + int whenBegin = tzBegin++; + int tz = RawParseUtils.parseTimeZoneOffset(raw, tzBegin, p); + boolean hasTz = p.value != tzBegin; + + whenBegin = lastIndexOfTrim(raw, ' ', whenBegin); + if (whenBegin < 0 || raw[whenBegin] != ' ') { + return new PushCertificateIdent(str, str, 0, 0); + } + int idEnd = whenBegin++; + long when = RawParseUtils.parseLongBase10(raw, whenBegin, p); + boolean hasWhen = p.value != whenBegin; + + if (hasTz && hasWhen) { + idEnd = whenBegin - 1; + } else { + // If either tz or when are non-numeric, mimic parsePersonIdent behavior and + // set them both to zero. + tz = 0; + when = 0; + if (hasTz && !hasWhen) { + // Only one trailing numeric field; assume User ID ends before this + // field, but discard its value. + idEnd = tzBegin - 1; + } else { + // No trailing numeric fields; User ID is whole raw value. + idEnd = raw.length; + } + } + String id = new String(raw, 0, idEnd, UTF_8); + + return new PushCertificateIdent(str, id, when * 1000L, tz); + } + + private final String raw; + private final String userId; + private final long when; + private final int tzOffset; + + /** + * Construct a new identity from an OpenPGP User ID. + * + * @param userId + * OpenPGP User ID; any UTF-8 string. + * @param when + * local time. + * @param tzOffset + * timezone offset; see {@link #getTimeZoneOffset()}. + */ + public PushCertificateIdent(String userId, long when, int tzOffset) { + this.userId = userId; + this.when = when; + this.tzOffset = tzOffset; + StringBuilder sb = new StringBuilder(userId).append(' ').append(when / 1000) + .append(' '); + PersonIdent.appendTimezone(sb, tzOffset); + raw = sb.toString(); + } + + private PushCertificateIdent(String raw, String userId, long when, + int tzOffset) { + this.raw = raw; + this.userId = userId; + this.when = when; + this.tzOffset = tzOffset; + } + + /** + * Get the raw string from which this identity was parsed. + *

+ * If the string was constructed manually, a suitable canonical string is + * returned. + *

+ * For the purposes of bytewise comparisons with other OpenPGP IDs, the string + * must be encoded as UTF-8. + * + * @return the raw string. + * @since 4.1 + */ + public String getRaw() { + return raw; + } + + /** + * @return the OpenPGP User ID, which may be any string. + * @since 4.1 + */ + public String getUserId() { + return userId; + } + + /** + * @return the name portion of the User ID. If no email address would be + * parsed by {@link #getEmailAddress()}, returns the full User ID with + * spaces trimmed. + * @since 4.1 + */ + public String getName() { + int nameEnd = userId.indexOf('<'); + if (nameEnd < 0 || userId.indexOf('>', nameEnd) < 0) { + nameEnd = userId.length(); + } + nameEnd--; + while (nameEnd >= 0 && userId.charAt(nameEnd) == ' ') { + nameEnd--; + } + int nameBegin = 0; + while (nameBegin < nameEnd && userId.charAt(nameBegin) == ' ') { + nameBegin++; + } + return userId.substring(nameBegin, nameEnd + 1); + } + + /** + * @return the email portion of the User ID, if one was successfully parsed + * from {@link #getUserId()}, or null. + * @since 4.1 + */ + public String getEmailAddress() { + int emailBegin = userId.indexOf('<'); + if (emailBegin < 0) { + return null; + } + int emailEnd = userId.indexOf('>', emailBegin); + if (emailEnd < 0) { + return null; + } + return userId.substring(emailBegin + 1, emailEnd); + } + + /** + * @return the timestamp of the identity. + * @since 4.1 + */ + public Date getWhen() { + return new Date(when); + } + + /** + * @return this person's declared time zone; null if the timezone is unknown. + * @since 4.1 + */ + public TimeZone getTimeZone() { + return PersonIdent.getTimeZone(tzOffset); + } + + /** + * @return this person's declared time zone as minutes east of UTC. If the + * timezone is to the west of UTC it is negative. + * @since 4.1 + */ + public int getTimeZoneOffset() { + return tzOffset; + } + + @Override + public boolean equals(Object o) { + return (o instanceof PushCertificateIdent) + && raw.equals(((PushCertificateIdent) o).raw); + } + + @Override + public int hashCode() { + return raw.hashCode(); + } + + @SuppressWarnings("nls") + @Override + public String toString() { + SimpleDateFormat fmt; + fmt = new SimpleDateFormat("EEE MMM d HH:mm:ss yyyy Z", Locale.US); + fmt.setTimeZone(getTimeZone()); + return getClass().getSimpleName() + + "[raw=\"" + raw + "\"," + + " userId=\"" + userId + "\"," + + " " + fmt.format(Long.valueOf(when)) + "]"; + } +} diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/transport/PushCertificateParser.java b/org.eclipse.jgit/src/org/eclipse/jgit/transport/PushCertificateParser.java index 1c9ce839b..cb6a8598b 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/transport/PushCertificateParser.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/transport/PushCertificateParser.java @@ -56,10 +56,8 @@ import org.eclipse.jgit.errors.PackProtocolException; import org.eclipse.jgit.internal.JGitText; -import org.eclipse.jgit.lib.PersonIdent; import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.transport.PushCertificate.NonceStatus; -import org.eclipse.jgit.util.RawParseUtils; /** * Parser for signed push certificates. @@ -85,7 +83,7 @@ public class PushCertificateParser { private static final String END_CERT = "push-cert-end\n"; //$NON-NLS-1$ private String version; - private PersonIdent pusher; + private PushCertificateIdent pusher; private String pushee; /** The nonce that was sent to the client. */ @@ -218,12 +216,12 @@ public void receiveHeader(PacketLineIn pckIn, boolean stateless) throw new PackProtocolException(MessageFormat.format( JGitText.get().pushCertificateInvalidFieldValue, VERSION, version)); } - String pusherStr = parseHeader(pckIn, PUSHER); - pusher = RawParseUtils.parsePersonIdent(pusherStr); + String rawPusher = parseHeader(pckIn, PUSHER); + pusher = PushCertificateIdent.parse(rawPusher); if (pusher == null) { throw new PackProtocolException(MessageFormat.format( JGitText.get().pushCertificateInvalidFieldValue, - PUSHER, pusherStr)); + PUSHER, rawPusher)); } pushee = parseHeader(pckIn, PUSHEE); receivedNonce = parseHeader(pckIn, NONCE); diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/util/RawParseUtils.java b/org.eclipse.jgit/src/org/eclipse/jgit/util/RawParseUtils.java index 3c2460cad..45c339fb4 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/util/RawParseUtils.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/util/RawParseUtils.java @@ -390,7 +390,28 @@ public static final int parseHexInt4(final byte digit) { * @return the timezone at this location, expressed in minutes. */ public static final int parseTimeZoneOffset(final byte[] b, int ptr) { - final int v = parseBase10(b, ptr, null); + return parseTimeZoneOffset(b, ptr, null); + } + + /** + * Parse a Git style timezone string. + *

+ * The sequence "-0315" will be parsed as the numeric value -195, as the + * lower two positions count minutes, not 100ths of an hour. + * + * @param b + * buffer to scan. + * @param ptr + * position within buffer to start parsing digits at. + * @param ptrResult + * optional location to return the new ptr value through. If null + * the ptr value will be discarded. + * @return the timezone at this location, expressed in minutes. + * @since 4.1 + */ + public static final int parseTimeZoneOffset(final byte[] b, int ptr, + MutableInteger ptrResult) { + final int v = parseBase10(b, ptr, ptrResult); final int tzMins = v % 100; final int tzHours = v / 100; return tzHours * 60 + tzMins; @@ -1081,7 +1102,17 @@ public static final int endOfParagraph(final byte[] b, final int start) { return ptr; } - private static int lastIndexOfTrim(byte[] raw, char ch, int pos) { + /** + * @param raw + * buffer to scan. + * @param ch + * character to find. + * @param pos + * starting position. + * @return last index of ch in raw, trimming spaces. + * @since 4.1 + */ + public static int lastIndexOfTrim(byte[] raw, char ch, int pos) { while (pos >= 0 && raw[pos] == ' ') pos--;