001    /**
002     * <copyright> 
003     *
004     * Copyright (c) 2004-2008 IBM Corporation and others.
005     * All rights reserved.   This program and the accompanying materials
006     * are made available under the terms of the Eclipse Public License v1.0
007     * which accompanies this distribution, and is available at
008     * http://www.eclipse.org/legal/epl-v10.html
009     * 
010     * Contributors: 
011     *   IBM - Initial API and implementation
012     *
013     * </copyright>
014     *
015     * $Id$
016     */
017    package org.eclipse.emf.common.archive;
018    
019    import java.io.File;
020    import java.io.FileInputStream;
021    import java.io.FileOutputStream;
022    import java.io.FilterInputStream;
023    import java.io.FilterOutputStream;
024    import java.io.IOException;
025    import java.io.InputStream;
026    import java.io.OutputStream;
027    import java.net.MalformedURLException;
028    import java.net.URL;
029    import java.net.URLConnection;
030    import java.util.zip.ZipEntry;
031    import java.util.zip.ZipFile;
032    import java.util.zip.ZipInputStream;
033    import java.util.zip.ZipOutputStream;
034    
035    import org.eclipse.emf.common.util.URI;
036    
037    /**
038     * A connection that can access an entry in an archive, and then recursively an entry in that archive, and so on.
039     * For example, it can be used just like jar: or zip:, only the archive paths can repeat, e.g.,
040     *<pre>
041     *  archive:file:///c:/temp/example.zip!/org/example/nested.zip!/org/example/deeply-nested.html
042     *</pre>
043     * The general recursive pattern is
044     *<pre>
045     *  archive:$nestedURL${/!$archivePath$}+
046     *</pre>
047     * So the nested URL for the example above is
048     *<pre>
049     *  file:///c:/temp/example.zip
050     *</pre>
051     * 
052     * <p>
053     * Since the nested URL may itself contain archive schemes,
054     * the subsequence of the archive paths that should be associated with the nested URL 
055     * is determined by finding the nth archive separator, i.e., the nth !/, 
056     * where n is the number of ":"s before the first "/" of the nested URL, i.e., the number of nested schemes.
057     * For example, for a more complex case where the nested URL is itself an archive-based scheme, e.g.,
058     *<pre>
059     *  archive:jar:file:///c:/temp/example.zip!/org/example/nested.zip!/org/example/deeply-nested.html
060     *</pre>
061     * the nested URL is correctly parsed to skip to the second archive separator as
062     *<pre>
063     *  jar:file:///c:/temp/example.zip!/org/example/nested.zip
064     *</pre>
065     * </p>
066     *
067     * <p>
068     * The logic for accessing archives can be tailored and reused independant from its usage as a URL connection.
069     * This is normally done by using the constructor {@link #ArchiveURLConnection(String)}
070     * and overriding {@link #createInputStream(String)} and {@link #createOutputStream(String)}.
071     * The behavior can be tailored by overriding {@link #emulateArchiveScheme()} and {@link #useZipFile()}.
072     * </p>
073     */
074    public class ArchiveURLConnection extends URLConnection
075    {
076      /**
077       * The cached string version of the {@link #url URL}.
078       */
079      protected String urlString;
080      
081      /**
082       * Constructs a new connection for the URL.
083       * @param url the URL of this connection.
084       */
085      public ArchiveURLConnection(URL url)
086      {
087        super(url);
088        urlString = url.toString();
089      }
090      
091      /**
092       * Constructs a new archive accessor.
093       * This constructor forwards a null URL to be super constructor, 
094       * so an instance built with this constructor <b>cannot</b> be used as a URLConnection.
095       * The logic for accessing archives and for delegating to the nested URL can be reused in other applications,
096       * without creating an URLs.
097       * @param url the URL of the archive.
098       */
099      protected ArchiveURLConnection(String url)
100      {
101        super(null);
102        urlString = url;
103      }
104      
105      /**
106       * </p>
107       * Returns whether the implementation will handle all the archive accessors directly.
108       * For example, whether
109       *<pre>
110       *  archive:jar:file:///c:/temp/example.zip!/org/example/nested.zip!/org/example/deeply-nested.html
111       *</pre>
112       * will be handled as if it were specified as
113       *<pre>
114       *  archive:file:///c:/temp/example.zip!/org/example/nested.zip!/org/example/deeply-nested.html
115       *</pre>
116       * Override this only if you are reusing the logic of retrieving an input stream into an archive 
117       * and hence are likely to be overriding createInputStream, 
118       * which is the point of delegation to the nested URL for recursive stream creation.
119       * </p>
120       * @return whether the implementation will handle all the archive accessors directly.
121       */
122      protected boolean emulateArchiveScheme()
123      {
124        return false;
125      }
126      
127      /**
128       * Returns whether to handle the special case of a nested URL with file: schema using a {@link ZipFile}.
129       * This gives more efficient direct access to the root entry, e.g., 
130       *<pre>
131       *  archive:file:///c:/temp/example.zip!/org/example/nested.html
132       *</pre>
133       * @return whether to handle the special case of a nested URL with file: schema using a ZipFile.
134       */
135      protected boolean useZipFile()
136      {
137        return false;
138      }
139            
140      /**
141       * Record that this is connected.
142       */
143      @Override
144      public void connect() throws IOException
145      {
146        connected = true;
147      }
148    
149      protected String getNestedURL() throws IOException
150      {
151        // There must be at least one archive path.
152        //
153        int archiveSeparator = urlString.indexOf("!/");
154        if (archiveSeparator < 0)
155        {
156          throw new MalformedURLException("missing archive separators " + urlString);
157        }
158    
159        // There needs to be another URL protocol right after the archive protocol, and not a "/".
160        //
161        int start = urlString.indexOf(':') + 1;
162        if (start > urlString.length() || urlString.charAt(start) == '/')
163        {
164          throw 
165            new IllegalArgumentException
166              ("archive protocol must be immediately followed by another URL protocol " + urlString);
167        }
168    
169        // Parse to extract the archives that will be delegated to the nested URL based on the number of schemes at the start.
170        //
171        for (int i = start, end = urlString.indexOf("/") - 1; (i = urlString.indexOf(":", i)) < end; )
172        {
173          if (emulateArchiveScheme())
174          {
175            // Skip a scheme for the archive accessor to be handled directly here.
176            //
177            start = ++i;
178          }
179          else
180          {
181            // Skip an archive accessor to be handled by delegation to the scheme in nested URL.
182            //
183            archiveSeparator = urlString.indexOf("!/", archiveSeparator + 2);
184            if (archiveSeparator < 0)
185            {
186              throw new MalformedURLException("too few archive separators " + urlString);
187            }
188            ++i;
189          }
190        }
191    
192        return urlString.substring(start, archiveSeparator);
193      }
194    
195      /**
196       * Creates the input stream for the URL.
197       * @return the input stream for the URL.
198       */
199      @Override
200      public InputStream getInputStream() throws IOException 
201      {
202        // Create the delegate URL.
203        //
204        String nestedURL = getNestedURL();
205              
206        // The cutoff point to the next archive.
207        //
208        int archiveSeparator = urlString.indexOf(nestedURL) + nestedURL.length();
209        int nextArchiveSeparator = urlString.indexOf("!/", archiveSeparator + 2);
210              
211        // Construct the input stream in a special efficient way for case of a file scheme.
212        //
213        InputStream inputStream;
214        ZipEntry inputZipEntry = null;
215        if (!useZipFile() || !nestedURL.startsWith("file:"))
216        {
217          // Just get the stream from the URL.
218          //
219          inputStream =  createInputStream(nestedURL);
220        }
221        else
222        {
223          // The name to be used for the entry.
224          //
225          String entry = 
226            URI.decode(nextArchiveSeparator < 0 ?
227                         urlString.substring(archiveSeparator + 2) :
228                         urlString.substring(archiveSeparator + 2, nextArchiveSeparator));
229                     
230          // Skip over this archive path to the next one, since we are handling this one special.
231          //
232          archiveSeparator = nextArchiveSeparator;
233          nextArchiveSeparator = urlString.indexOf("!/", archiveSeparator + 2);
234                
235          // Go directly to the right entry in the zip file, 
236          // get the stream, 
237          // and wrap it so that closing it closes the zip file.
238          //
239          final ZipFile zipFile = new ZipFile(URI.decode(nestedURL.substring(5)));
240          inputZipEntry = zipFile.getEntry(entry);
241          InputStream zipEntryInputStream = inputZipEntry == null ? null : zipFile.getInputStream(inputZipEntry);
242          if (zipEntryInputStream == null)
243          {
244            try
245            {
246              zipFile.close();
247            }
248            catch (Throwable throwable)
249            {
250              // Ignore because we'll throw a different IO exception
251            }
252            throw new IOException("Archive entry not found " + urlString);
253          }
254          inputStream = 
255            new FilterInputStream(zipEntryInputStream)
256            {
257              @Override
258              public void close() throws IOException
259              {
260                super.close();
261                zipFile.close();
262              }
263            };
264        }
265              
266        // Loop over the archive paths.
267        //
268        LOOP:
269        while (archiveSeparator > 0)
270        {
271          inputZipEntry = null;
272    
273          // The entry name to be matched.
274          //
275          String entry = 
276            URI.decode(nextArchiveSeparator < 0 ?
277                         urlString.substring(archiveSeparator + 2) :
278                         urlString.substring(archiveSeparator + 2, nextArchiveSeparator));
279                
280          // Wrap the input stream as a zip stream to scan it's contents for a match.
281          //
282          ZipInputStream zipInputStream = new ZipInputStream(inputStream);
283          while (zipInputStream.available() >= 0)
284          {
285            ZipEntry zipEntry = zipInputStream.getNextEntry();
286            if (zipEntry == null)
287            {
288              break;
289            }
290            else if (entry.equals(zipEntry.getName()))
291            {
292              inputZipEntry = zipEntry;
293              inputStream = zipInputStream;
294                      
295              // Skip to the next archive path and continue the loop.
296              //
297              archiveSeparator = nextArchiveSeparator;
298              nextArchiveSeparator = urlString.indexOf("!/", archiveSeparator + 2);
299              continue LOOP;
300            }
301          }
302    
303          zipInputStream.close();
304          throw new IOException("Archive entry not found " + urlString);
305        }
306    
307        return yield(inputZipEntry, inputStream);
308      }
309    
310      protected InputStream yield(ZipEntry zipEntry, InputStream inputStream) throws IOException
311      {
312        return inputStream;
313      }
314    
315      /**
316       * Creates an input stream for the nested URL by calling {@link URL#openStream() opening} a stream on it.
317       * @param nestedURL the nested URL for which a stream is required.
318       * @return the open stream of the nested URL.
319       */
320      protected InputStream createInputStream(String nestedURL) throws IOException
321      {
322        return new URL(nestedURL).openStream();
323      }
324      
325      /**
326       * Creates the output stream for the URL.
327       * @return the output stream for the URL.
328       */
329      @Override
330      public OutputStream getOutputStream() throws IOException
331      {
332        return getOutputStream(false, -1);
333      }
334    
335      public void delete() throws IOException
336      {
337        getOutputStream(true, -1).close();
338      }
339      
340      public void setTimeStamp(long timeStamp) throws IOException
341      {
342        getOutputStream(false, timeStamp).close();
343      }
344      
345      private OutputStream getOutputStream(boolean delete, long timeStamp) throws IOException
346      {
347        // Create the delegate URL
348        //
349        final String nestedURL = getNestedURL();
350        
351        // Create a temporary file where the existing contents of the archive can be written 
352        // before the new contents are added.
353        //
354        final File tempFile = File.createTempFile("Archive", "zip");
355        tempFile.deleteOnExit();
356        
357        // Record the input and output streams for closing in case of failure so that handles are not left open.
358        //
359        InputStream sourceInputStream =  null;
360        OutputStream tempOutputStream = null;
361        try
362        {
363          // Create the output stream to the temporary file and the input stream for the delegate URL.
364          //
365          tempOutputStream = new FileOutputStream(tempFile);
366          try
367          {
368            sourceInputStream =  createInputStream(nestedURL);
369          }
370          catch (IOException exception)
371          {
372            // Continue processing if the file doesn't exist so that we try create a new empty one.
373          }
374          
375          // Record them as generic streams to record state during the loop that emulates recursion.
376          //
377          OutputStream outputStream = tempOutputStream;
378          InputStream inputStream =  sourceInputStream;
379          
380          // The cutoff point to the next archive.
381          //
382          int archiveSeparator = urlString.indexOf(nestedURL) + nestedURL.length();
383          int nextArchiveSeparator = urlString.indexOf("!/", archiveSeparator + 2);
384          
385          // The most deeply nested output stream that will be returned wrapped as the result.
386          //
387          ZipOutputStream zipOutputStream;
388          
389          // A buffer for transferring archive contents.
390          //
391          final byte [] bytes = new byte [4096];
392                  
393          // We expect there to be at least one archive path.
394          //
395          ZipEntry outputZipEntry;
396          boolean found = false;
397          for (;;)
398          {
399            // The name that will be used as the archive entry.
400            //
401            String entry = 
402              URI.decode(nextArchiveSeparator < 0 ?
403                           urlString.substring(archiveSeparator + 2) :
404                           urlString.substring(archiveSeparator + 2, nextArchiveSeparator));
405                 
406            // Wrap the current result as a zip stream, and record it for loop-based recursion.
407            //
408            zipOutputStream = null;
409            
410            // Wrap the current input as a zip stream, and record it for loop-based recursion.
411            //
412            ZipInputStream zipInputStream = inputStream == null ? null : new ZipInputStream(inputStream);
413            inputStream = zipInputStream;
414            
415            // Loop over the entries in the zip stream.
416            //
417            while (zipInputStream != null && zipInputStream.available() >= 0)
418            {
419              // If this entry isn't the end marker 
420              // and isn't the matching one that we are replacing...
421              //
422              ZipEntry zipEntry = zipInputStream.getNextEntry();
423              if (zipEntry == null)
424              {
425                break;
426              }
427              else
428              {
429                boolean match = entry.equals(zipEntry.getName());
430                if (!found)
431                {
432                  found = match && nextArchiveSeparator < 0;
433                }
434                if (timeStamp != -1 || !match)
435                {
436                  if (zipOutputStream == null)
437                  {
438                    zipOutputStream =  new ZipOutputStream(outputStream);
439                    outputStream = zipOutputStream;
440                  }
441                  // Transfer the entry and its contents.
442                  //
443                  if (timeStamp != -1 && match && nextArchiveSeparator < 0)
444                  {
445                    zipEntry.setTime(timeStamp);
446                  }
447                  zipOutputStream.putNextEntry(zipEntry);
448                  for (int size; (size = zipInputStream.read(bytes, 0, bytes.length)) > -1; )
449                  {
450                    zipOutputStream.write(bytes, 0, size);
451                  }
452                }
453              }
454            }
455    
456            // Find the next archive path and continue "recursively" if there is one.
457            //
458            archiveSeparator = nextArchiveSeparator;
459            nextArchiveSeparator = urlString.indexOf("!/", archiveSeparator + 2);
460    
461            if ((delete || timeStamp != -1) && archiveSeparator < 0)
462            {
463               if (!found)
464               {
465                 throw new IOException("Archive entry not found " + urlString);
466               }
467              // Create no entry since we are deleting and return immediately.
468              //
469              outputZipEntry = null;
470              break;
471            }
472            else 
473            {
474              // Create a new or replaced entry and continue processing the remaining archives.
475              //
476              outputZipEntry = new ZipEntry(entry);
477              if (zipOutputStream == null)
478              {
479                zipOutputStream =  new ZipOutputStream(outputStream);
480                outputStream = zipOutputStream;
481              }
482              zipOutputStream.putNextEntry(outputZipEntry);
483              if (archiveSeparator > 0)
484              {
485                continue;
486              }
487              else
488              {
489                break;
490              }
491            }
492          }
493          
494          // Ensure that it won't be closed in the finally block.
495          //
496          tempOutputStream = null;
497          
498          // Wrap the deepest result so that on close, the results are finally transferred.
499          //
500          final boolean deleteRequired = sourceInputStream != null;
501          FilterOutputStream result = 
502            new FilterOutputStream(zipOutputStream == null ? outputStream : zipOutputStream)
503            {
504              protected boolean isClosed;
505              
506              @Override
507              public void close() throws IOException
508              {
509                // Make sure we close only once.
510                //
511                if (!isClosed)
512                {
513                  isClosed = true;
514                  
515                  // Close for real so that the temporary file is ready to be read.
516                  //
517                  super.close();
518                  
519                  boolean useRenameTo = nestedURL.startsWith("file:");
520                  
521                  // If the delegate URI can be handled as a file, 
522                  // we'll hope that renaming it will be really efficient.
523                  //
524                  if (useRenameTo)
525                  {
526                    File targetFile = new File(URI.decode(nestedURL.substring(5)));
527                    if (deleteRequired && !targetFile.delete())
528                    {
529                      throw new IOException("cannot delete " + targetFile.getPath());
530                    }
531                    else if (!tempFile.renameTo(targetFile))
532                    {
533                      useRenameTo = false;
534                    }
535                  }
536                  if (!useRenameTo)
537                  {
538                    // Try to transfer it by reading the contents of the temporary file 
539                    // and writing them to the output stream of the delegate.
540                    //
541                    InputStream inputStream = null;
542                    OutputStream outputStream = null;
543                    try
544                    {
545                      inputStream = new FileInputStream(tempFile);
546                      outputStream = createOutputStream(nestedURL);
547                      for (int size; (size = inputStream.read(bytes, 0, bytes.length)) > -1; )
548                      {
549                        outputStream.write(bytes, 0, size);
550                      }
551                    }
552                    finally
553                    {
554                      // Make sure they are closed no matter what bad thing happens.
555                      //
556                      if (inputStream != null) 
557                      {
558                        inputStream.close();
559                      }
560                      if (outputStream != null) 
561                      {
562                        outputStream.close();
563                      }
564                    }
565                  }
566                  // Delete the temporary file early if possible
567                  //
568                  tempFile.delete();
569                }
570              }
571            };
572          return outputZipEntry == null ? result : yield(outputZipEntry, result);
573        }
574        finally
575        {
576          // Close in case of failure to complete.
577          //
578          if (tempOutputStream != null)
579          {
580            tempOutputStream.close();
581          }
582          
583          // Close if we created this.
584          //
585          if (sourceInputStream != null)
586          {
587            sourceInputStream.close();
588          }
589        }
590      }
591    
592      protected OutputStream yield(ZipEntry zipEntry, OutputStream outputStream) throws IOException
593      {
594        return outputStream;
595      }
596      
597      
598      /**
599       * Creates an output stream for the nested URL by calling {@link URL#openConnection() opening} a stream on it.
600       * @param nestedURL the nested URL for which a stream is required.
601       * @return the open stream of the nested URL.
602       */
603      protected OutputStream createOutputStream(String nestedURL) throws IOException
604      {
605        URL url = new URL(nestedURL);
606        URLConnection urlConnection = url.openConnection();
607        urlConnection.setDoOutput(true);
608        return urlConnection.getOutputStream(); 
609      }
610    }