/* ***** BEGIN LICENSE BLOCK ***** * * The contents of this file are subject to the Mozilla Public License Version * 1.1 (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.mozilla.org/MPL/ * * Software distributed under the License is distributed on an "AS IS" basis, * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License * for the specific language governing rights and limitations under the * License. * * The Original Code is com.jkristian.protocol.p4. * * The Initial Developer of the Original Code is John M. Kristian. * Portions created by the Initial Developer are Copyright (C) 2005 * the Initial Developer. All Rights Reserved. * * Contributor(s): * John M. Kristian <jk2005@engineer.com> * * ***** END LICENSE BLOCK ***** */ package com.perforce.api.protocol.p4; import com.jkristian.cli.CommandLine; import com.jkristian.io.ByteCopier; import com.jkristian.io.ByteArrayBuffer; import com.jkristian.io.LineInputStream; import com.jkristian.io.LineReader; import com.jkristian.io.ProcessInputStream; import com.jkristian.lang.ByteSequence; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileNotFoundException; import java.io.FilterOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.PushbackInputStream; import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; import java.net.URLConnection; import java.net.URLDecoder; import java.net.URLStreamHandler; import java.net.UnknownServiceException; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.SimpleTimeZone; import java.util.StringTokenizer; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * The handler for the URI scheme "p4". This class enables URL-based software * (e.g. XML parsers and class loaders) to read files from a Perforce depot or * workspace. To install this handler, run the JVM with * -Djava.protocol.handler.pkgs=com.perforce.api.protocol, or call * {@link Handler#install Handler.install}. For a fuller explanation, see <a * href="http://java.sun.com/developer/onlineTraining/protocolhandlers/">http://java.sun.com/developer/onlineTraining/protocolhandlers/</a> * <p> * Supported URLs have the form * <code>p4://user:password@host:port//depot/file%23version</code> where: * <ul> * <li>user corresponds to P4USER.</li> * <li>password corresponds to P4PASSWD.</li> * <li>host:port corresponds to P4PORT.</li> * <li>//depot/file%23version is a Perforce <a * href="http://www.perforce.com/perforce/doc.051/manuals/cmdref/o.fspecs.html">file * specification </a> with an optional revision specifier.</li> * </ul> * A '#' must be URL-encoded as %23 (to prevent its interpretation as a fragment * reference). Characters are encoded in UTF-8. * <p> * getHeaderField(0) is the first line of output from `p4 print`; * getHeaderFieldKey(0) == null. A Content-Type header is derived from the file * name or Perforce file type. The InputStream produces the file contents. * <p> * This implementation requires the Perforce command line program `p4` to be * available. (Most of the work is done by executing `p4 print`.) Also, the * implementation depends on John Kristian's <a * href="http://javanuggets.sourceforge.net/io.html">I/O classes </a>. * <p> * Writing is not supported; that is, you can't send data to a file via a p4: * URL. The URL scheme "p4" is not registered with IANA (yet). * * @author <a href="mailto:jk2005@engineer.com">John Kristian </a> */ public class Handler extends URLStreamHandler { /** * Make this Handler available to the default URLStreamHandlerFactory. That * is, add PKG_NAME to the end of the System property named PKGS, unless the * PKGS property already contains PKG_NAME (in which case, do nothing). */ public static void install() { String oldValue = System.getProperty(PKGS); if (!isInstalled(oldValue)) { String newValue = PKG_NAME; if (oldValue != null) { newValue = oldValue + "|" + newValue; } System.setProperty(PKGS, newValue); } } protected static boolean isInstalled(String value) { if (value != null && value.indexOf(PKG_NAME) >= 0) { StringTokenizer tok = new StringTokenizer(value, "|"); while (tok.hasMoreTokens()) { String installed = tok.nextToken(); if (PKG_NAME.equals(installed)) { return true; } } } return false; } public static final int DEFAULT_PORT = 1666; protected int getDefaultPort() { return DEFAULT_PORT; } /** The name of the System property wherein URLStreamHandlers are installed. */ protected static final String PKGS = "java.protocol.handler.pkgs"; private static String getPkgName() { String name = Handler.class.getPackage().getName(); name = name.substring(0, name.lastIndexOf('.')); return name; } /** The value of PKGS for this Handler. */ protected static final String PKG_NAME = getPkgName(); // protected void parseURL(URL u, String spec, int start, int limit) // { // super.parseURL(u, spec, start, limit); // } protected URLConnection openConnection(URL url) throws IOException { return new P4URLConnection(url); } protected static class P4URLConnection extends URLConnection { // TODO: support the request property "Accept-Charset" as in HTTP. // The implementation will execute `p4 -C charset print`. // TODO? support writing to a workspace file (`p4 edit` it if necessary) // or depot file (submit a new version). // TODO? take P4CLIENT or P4CHARSET as query string parameters or // request properties. // TODO? implement getLastModified and support ifModifiedSince. // The p4 command line interface doesn't really support this; // one must query the Perforce change (another round trip). // TODO? if (allowUserInteraction) prompt for a password, if needed. // It's challenging to read a password without echoing it; see // http://java.sun.com/developer/technicalArticles/Security/pwordmask/ public P4URLConnection(URL url) { super(url); } /** The character encoding for URLs. */ protected static final String UTF8 = "UTF-8"; /** The file spec from url (not header). */ protected String fileSpec = null; /** The first line of output from `p4 print. */ protected String header = null; protected String contentType = null; protected Map headerFields = null; protected InputStream inputStream = null; /** The header name for contentType */ protected static final String CONTENT_TYPE = "Content-Type"; /** The header name for expiration */ protected static final String EXPIRATION = "Expiration"; protected static final String[] STRING_ARRAY = new String[0]; /** * Throws UnknownServiceException to indicate that output is not * supported. */ public OutputStream getOutputStream() throws UnknownServiceException { throw new UnknownServiceException("output is not supported"); } public Map getHeaderFields() { if (headerFields == null) { Map fields = new HashMap(); for (int n = 0; true; ++n) { String value = getHeaderField(n); if (value == null) break; List values = new ArrayList(1); values.add(value); fields.put(getHeaderFieldKey(n), values); } headerFields = Collections.unmodifiableMap(fields); } return headerFields; } public String getHeaderFieldKey(int n) { connectIfNecessary(); switch (n) { case 1: if (getContentType() != null) return CONTENT_TYPE; break; case 2: if (getExpiration() != 0) return EXPIRATION; break; default: } return null; } public String getHeaderField(int n) { connectIfNecessary(); switch (n) { case 0: return header; case 1: return getContentType(); case 2: long expiration = getExpiration(); return (expiration == 0) ? null : RFC1123_DATE.format(new Date(expiration)); default: return null; } } private static final DateFormat RFC1123_DATE = new SimpleDateFormat( "EEE, dd MMM yyyy HH:mm:ss 'GMT'"); static { RFC1123_DATE.setTimeZone(new SimpleTimeZone(0, "GMT")); } public long getExpiration() { connectIfNecessary(); Matcher matcher = IMMUTABLE_VERSION.matcher(fileSpec); if (matcher.find()) { return (new Date()).getTime() + (365 * DAYS); // per RFC 2068 section 14.21 } return 0; // unknown } /** Matches a file spec that will always identify the same file version. */ private static final Pattern IMMUTABLE_VERSION = Pattern.compile("^//.*[#@]\\d*$"); private static final long DAYS = 24 * 60 * 60 * 1000; public String getContentType() { connectIfNecessary(); if (contentType == null) { // Figure out the contentType. For background see // http://www.perforce.com/perforce/doc.051/manuals/cmdref/o.ftypes.html // http://www.perforce.com/perforce/doc.051/user/i18nnotes.txt // and http://www.faqs.org/rfcs/rfc2068.html (Content-Type) Matcher matcher = P4PRINT_HEADER.matcher(header); if (matcher.find()) { String file = matcher.group(1); String fileType = matcher.group(4); if (fileType.endsWith("apple")) { // The content is specifically AppleSingle. contentType = "application/applefile"; // RFC 1740 } else { // Other types say more about Perforce's behavior // than about how data should be presented to people. // So the file name is often more informative: contentType = guessContentTypeFromName(file); } if (contentType == null) { // Map the fileType to a MIME type: if (fileType.endsWith("text")) { contentType = "text/plain"; } else if (fileType.endsWith("unicode")) { contentType = "text/plain"; } else if (fileType.endsWith("symlink")) { // TODO: is there a standard MIME type for this? contentType = "text/symlink"; } else if (fileType.endsWith("binary")) { contentType = "application/octet-stream"; } else { contentType = "application/" + fileType; } } if (fileType.endsWith("unicode")) { // The server stores UTF-8, and `p4 print` converts // to a configured charset: contentType += "; charset=" + getP4Charset(); } // Other Perforce file types don't imply a specific charset. // The "text" file type implies that line endings are // encoded like ASCII (CR, LF or CR LF), but there are many // non-ASCII charsets for which this is true (e.g. UTF-8). } } return contentType; } private static final Pattern P4PRINT_HEADER = Pattern .compile("^(//[^#]*)#(\\d*) - (\\w*) .* \\((\\w*)\\)$"); private String p4Charset = null; /** The current value of the Perforce environment variable P4CHARSET. */ protected String getP4Charset() { if (p4Charset == null) { p4Charset = UTF8; // by default; but try to learn more: try { LineReader reader = new LineReader(new ProcessInputStream(Runtime.getRuntime() .exec("p4 set " + P4CHARSET))); try { for (String line; null != (line = LineReader.toString(reader.readLine()));) { Matcher matcher = P4SET.matcher(line); if (matcher.find() && P4CHARSET.equals(matcher.group(1))) { p4Charset = matcher.group(2); break; } } } finally { reader.close(); } } catch (IOException ohWell) { } } return p4Charset; } private static final String P4CHARSET = "P4CHARSET"; private static final Pattern P4SET = Pattern.compile("^(\\w*)=([^ ]*)"); /** Call connect(); unless it's already been called. */ protected void connectIfNecessary() { if (!connected) { try { connect(); } catch (IOException e) { connectException = e; } } } /** * An exception that propagated from connect() and should be propagated * to a caller of this class, ASAP. We aim to report each failure once. */ private IOException connectException = null; public InputStream getInputStream() throws IOException { if (!connected) connect(); return inputStream; } public void connect() throws IOException { if (connectException != null) { try { throw connectException; } finally { connectException = null; } } if (connected) { return; // as required by the inherited interface contract. } String path = url.getPath(); if (path.startsWith("//")) { // a depot file specification // Normalize the path: try { URI uri = new URI(url.getProtocol(), url.getHost(), path, url.getRef()); URI normal = uri.normalize(); if (normal != uri) { String normalPath = normal.getPath(); // URI.normalize removes a leading '/' (on Unix). // Put it back: if (normalPath.startsWith("//")) { path = normalPath; } else if (normalPath.startsWith("/")) { path = "/" + normalPath; } else { path = "//" + normalPath; } } } catch (URISyntaxException ohWell) { } } path = URLDecoder.decode(path, UTF8); if (File.separatorChar == '\\' && path.matches("/[a-zA-Z]:.*")) { // Windows path = path.substring(1); // remove the leading '/' } List command = new ArrayList(3); command.add("p4"); addGlobalOptions(command); command.add("print"); command.add(path); fileSpec = path; Runtime runtime = Runtime.getRuntime(); Process source = runtime.exec((String[]) command.toArray(STRING_ARRAY)); PushbackInputStream dataStream = new PushbackInputStream(source.getInputStream()); // The expected dataStream is a header line followed by the file // contents or, if something goes wrong, no data at all. { // Collect error data in a buffer: ByteArrayBuffer errorBuffer = new ByteArrayBuffer(); SteerableOutputStream errorTarget = new SteerableOutputStream(errorBuffer); Thread errorReader = new Thread( new ByteCopier(source.getErrorStream(), errorTarget)); errorReader.start(); LineInputStream dataLines = new LineInputStream(dataStream); ByteSequence headerLine = dataLines.trimLine(dataLines.readLine()); if (headerLine != null) { header = headerLine.toString(getP4Charset()); // Redirect the error stream to System.err: synchronized (errorTarget) { // pause the errorReader errorTarget.getOutput().close(); errorBuffer.writeTo(System.err); errorTarget.setOutput(System.err); } } else { // No header, no data. // Collect all the error messages: while (true) { try { errorReader.join(); break; } catch (InterruptedException ignored) { // and try again. } } errorTarget.close(); // Propagate an exception containing the error messages: String message = dataLines.trimLine(errorBuffer).toString(getP4Charset()); if (message == null || message.length() <= 0) { message = path; } throw new FileNotFoundException(message); } } // TODO: inputStream = new TeeInputStream(header0, dataStream); inputStream = dataStream; connected = true; } private void addGlobalOptions(List command) throws UnsupportedEncodingException { String user = url.getUserInfo(); if (user != null) { int colon = user.indexOf(':'); if (colon >= 0) { command.add("-P"); command.add(URLDecoder.decode(user.substring(colon + 1), UTF8)); user = (colon == 0) ? null : user.substring(0, colon); } else if (user.length() <= 0) { user = null; } } if (user != null) { command.add("-u"); command.add(URLDecoder.decode(user, UTF8)); } String host = url.getHost(); if (host != null && host.length() > 0) { command.add("-p"); int port = url.getPort(); if (port <= 0) port = DEFAULT_PORT; command.add(host + ":" + port); } } /** * A simple filter that synchronizes each output method and enables * changing the target OutputStream. * * @author John Kristian */ protected class SteerableOutputStream extends FilterOutputStream { public SteerableOutputStream(OutputStream out) { super(out); } public void setOutput(OutputStream out) { this.out = out; } public OutputStream getOutput() { return this.out; } public synchronized void write(int datum) throws IOException { super.write(datum); } public synchronized void write(byte[] data) throws IOException { super.write(data); } public synchronized void write(byte[] data, int offset, int length) throws IOException { super.write(data, offset, length); } public synchronized void flush() throws IOException { super.flush(); } public synchronized void close() throws IOException { super.close(); } } // TODO: Cache copies of files that have been read: // // static Map header2data = new WeakHashMap(); // // public static final class TeeInputStream extends FilterInputStream // { // public TeeInputStream(String header, InputStream in) ... // header2data.put(header, a copy of the data) // } } /** Test p4 URLs for each of the given file specifications. */ public static void main(String[] fileSpecs) throws Exception { install(); for (int f = 0; f < fileSpecs.length; ++f) { String fileSpec = fileSpecs[f]; URL url = new URL("p4://public.perforce.com" + fileSpec.replaceAll("\\#", "%23")); CommandLine p4print = new CommandLine("p4 print " + fileSpec); PushbackInputStream stream = new PushbackInputStream(new ProcessInputStream(p4print .exec(), new ByteArrayBuffer() /* ignored */)); LineInputStream lines = new LineInputStream(stream); if (lines.readLine() == null) { try { url.openConnection().getInputStream().read(); throw new AssertionError("data from " + url); } catch (FileNotFoundException expected) { } } else { ByteSequence expected = ByteCopier.readAll(stream); ByteSequence actual = ByteCopier.readAll(url.openConnection().getInputStream()); if (!expected.equals(actual)) { throw new AssertionError("wrong data from " + url); } } } } }
# | Change | User | Description | Committed | |
---|---|---|---|---|---|
#2 | 5054 | John Kristian |
Added a test case for p4 URLs. This software requires io-v0_5 or later. |
||
#1 | 5047 | John Kristian |
This version builds and runs. It depends on Java 1.4 and com.jkristian.io. |
||
//guest/john_kristian/p4URL/src/com/jkristian/protocol/p4/Handler.java | |||||
#12 | 5023 | John Kristian | upgraded to io-v0_3 | ||
#11 | 5022 | John Kristian | beefed up newline handling | ||
#10 | 5021 | John Kristian | cross-reference the I/O packages (from SourceForge) | ||
#9 | 5020 | John Kristian | method renamed | ||
#8 | 5018 | John Kristian |
Refined getContentType algorithm. Don't ignore IOException. |
||
#7 | 5017 | John Kristian | add a charset only if p4Type.endsWith("unicode") | ||
#6 | 5016 | John Kristian | added Content-Type header | ||
#5 | 5015 | John Kristian | commentary | ||
#4 | 5014 | John Kristian | refined | ||
#3 | 5008 | John Kristian | Don't normalize //depot to /depot. | ||
#2 | 5007 | John Kristian |
Handle relative URIs (e.g. ../package.dtd). |
||
#1 | 5006 | John Kristian | Enable URL-based Java applications to get files from a Perforce depot. |