// Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. namespace WixToolset.Dtf.Compression { using System; using System.IO; using System.Collections.Generic; /// /// Provides a basic implementation of the archive pack and unpack stream context /// interfaces, based on a list of archive files, a default directory, and an /// optional mapping from internal to external file paths. /// /// /// This class can also handle creating or extracting chained archive packages. /// public class ArchiveFileStreamContext : IPackStreamContext, IUnpackStreamContext { private IList archiveFiles; private string directory; private IDictionary files; private bool extractOnlyNewerFiles; private bool enableOffsetOpen; #region Constructors /// /// Creates a new ArchiveFileStreamContext with a archive file and /// no default directory or file mapping. /// /// The path to a archive file that will be /// created or extracted. public ArchiveFileStreamContext(string archiveFile) : this(archiveFile, null, null) { } /// /// Creates a new ArchiveFileStreamContext with a archive file, default /// directory and mapping from internal to external file paths. /// /// The path to a archive file that will be /// created or extracted. /// The default root directory where files will be /// located, optional. /// A mapping from internal file paths to external file /// paths, optional. /// /// If the mapping is not null and a file is not included in the mapping, /// the file will be skipped. /// If the external path in the mapping is a simple file name or /// relative file path, it will be concatenated onto the default directory, /// if one was specified. /// For more about how the default directory and files mapping are /// used, see and /// . /// public ArchiveFileStreamContext( string archiveFile, string directory, IDictionary files) : this(new string[] { archiveFile }, directory, files) { if (archiveFile == null) { throw new ArgumentNullException("archiveFile"); } } /// /// Creates a new ArchiveFileStreamContext with a list of archive files, /// a default directory and a mapping from internal to external file paths. /// /// A list of paths to archive files that will be /// created or extracted. /// The default root directory where files will be /// located, optional. /// A mapping from internal file paths to external file /// paths, optional. /// /// When creating chained archives, the list /// should include at least enough archives to handle the entire set of /// input files, based on the maximum archive size that is passed to the /// .. /// If the mapping is not null and a file is not included in the mapping, /// the file will be skipped. /// If the external path in the mapping is a simple file name or /// relative file path, it will be concatenated onto the default directory, /// if one was specified. /// For more about how the default directory and files mapping are used, /// see and /// . /// public ArchiveFileStreamContext( IList archiveFiles, string directory, IDictionary files) { if (archiveFiles == null || archiveFiles.Count == 0) { throw new ArgumentNullException("archiveFiles"); } this.archiveFiles = archiveFiles; this.directory = directory; this.files = files; } #endregion #region Properties /// /// Gets or sets the list of archive files that are created or extracted. /// /// The list of archive files that are created or extracted. public IList ArchiveFiles { get { return this.archiveFiles; } } /// /// Gets or sets the default root directory where files are located. /// /// The default root directory where files are located. /// /// For details about how the default directory is used, /// see and . /// public string Directory { get { return this.directory; } } /// /// Gets or sets the mapping from internal file paths to external file paths. /// /// A mapping from internal file paths to external file paths. /// /// For details about how the files mapping is used, /// see and . /// public IDictionary Files { get { return this.files; } } /// /// Gets or sets a flag that can prevent extracted files from overwriting /// newer files that already exist. /// /// True to prevent overwriting newer files that already exist /// during extraction; false to always extract from the archive regardless /// of existing files. public bool ExtractOnlyNewerFiles { get { return this.extractOnlyNewerFiles; } set { this.extractOnlyNewerFiles = value; } } /// /// Gets or sets a flag that enables creating or extracting an archive /// at an offset within an existing file. (This is typically used to open /// archive-based self-extracting packages.) /// /// True to search an existing package file for an archive offset /// or the end of the file;/ false to always create or open a plain /// archive file. public bool EnableOffsetOpen { get { return this.enableOffsetOpen; } set { this.enableOffsetOpen = value; } } #endregion #region IPackStreamContext Members /// /// Gets the name of the archive with a specified number. /// /// The 0-based index of the archive within /// the chain. /// The name of the requested archive. May be an empty string /// for non-chained archives, but may never be null. /// This method returns the file name of the archive from the /// list with the specified index, or an empty /// string if the archive number is outside the bounds of the list. The /// file name should not include any directory path. public virtual string GetArchiveName(int archiveNumber) { if (archiveNumber < this.archiveFiles.Count) { return Path.GetFileName(this.archiveFiles[archiveNumber]); } return String.Empty; } /// /// Opens a stream for writing an archive. /// /// The 0-based index of the archive within /// the chain. /// The name of the archive that was returned /// by . /// True if the stream should be truncated when /// opened (if it already exists); false if an existing stream is being /// re-opened for writing additional data. /// Instance of the compression engine /// doing the operations. /// A writable Stream where the compressed archive bytes will be /// written, or null to cancel the archive creation. /// /// This method opens the file from the list /// with the specified index. If the archive number is outside the bounds /// of the list, this method returns null. /// If the flag is set, this method /// will seek to the start of any existing archive in the file, or to the /// end of the file if the existing file is not an archive. /// public virtual Stream OpenArchiveWriteStream( int archiveNumber, string archiveName, bool truncate, CompressionEngine compressionEngine) { if (archiveNumber >= this.archiveFiles.Count) { return null; } if (String.IsNullOrEmpty(archiveName)) { throw new ArgumentNullException("archiveName"); } // All archives must be in the same directory, // so always use the directory from the first archive. string archiveFile = Path.Combine( Path.GetDirectoryName(this.archiveFiles[0]), archiveName); Stream stream = File.Open( archiveFile, (truncate ? FileMode.OpenOrCreate : FileMode.Open), FileAccess.ReadWrite); if (this.enableOffsetOpen) { long offset = compressionEngine.FindArchiveOffset( new DuplicateStream(stream)); // If this is not an archive file, append the archive to it. if (offset < 0) { offset = stream.Length; } if (offset > 0) { stream = new OffsetStream(stream, offset); } stream.Seek(0, SeekOrigin.Begin); } if (truncate) { // Truncate the stream, in case a larger old archive starts here. stream.SetLength(0); } return stream; } /// /// Closes a stream where an archive package was written. /// /// The 0-based index of the archive within /// the chain. /// The name of the archive that was previously /// returned by . /// A stream that was previously returned by /// and is now ready to be closed. public virtual void CloseArchiveWriteStream( int archiveNumber, string archiveName, Stream stream) { if (stream != null) { stream.Close(); FileStream fileStream = stream as FileStream; if (fileStream != null) { string streamFile = fileStream.Name; if (!String.IsNullOrEmpty(archiveName) && archiveName != Path.GetFileName(streamFile)) { string archiveFile = Path.Combine( Path.GetDirectoryName(this.archiveFiles[0]), archiveName); if (File.Exists(archiveFile)) { File.Delete(archiveFile); } File.Move(streamFile, archiveFile); } } } } /// /// Opens a stream to read a file that is to be included in an archive. /// /// The path of the file within the archive. /// The returned attributes of the opened file, /// to be stored in the archive. /// The returned last-modified time of the /// opened file, to be stored in the archive. /// A readable Stream where the file bytes will be read from /// before they are compressed, or null to skip inclusion of the file and /// continue to the next file. /// /// This method opens a file using the following logic: /// /// If the and the mapping /// are both null, the path is treated as relative to the current directory, /// and that file is opened. /// If the is not null but the /// mapping is null, the path is treated as relative to that directory, and /// that file is opened. /// If the is null but the /// mapping is not null, the path parameter is used as a key into the mapping, /// and the resulting value is the file path that is opened, relative to the /// current directory (or it may be an absolute path). If no mapping exists, /// the file is skipped. /// If both the and the /// mapping are specified, the path parameter is used as a key into the /// mapping, and the resulting value is the file path that is opened, relative /// to the specified directory (or it may be an absolute path). If no mapping /// exists, the file is skipped. /// /// public virtual Stream OpenFileReadStream( string path, out FileAttributes attributes, out DateTime lastWriteTime) { string filePath = this.TranslateFilePath(path); if (filePath == null) { attributes = FileAttributes.Normal; lastWriteTime = DateTime.Now; return null; } attributes = File.GetAttributes(filePath); lastWriteTime = File.GetLastWriteTime(filePath); return File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); } /// /// Closes a stream that has been used to read a file. /// /// The path of the file within the archive; the same as /// the path provided when the stream was opened. /// A stream that was previously returned by /// and is now ready to be closed. public virtual void CloseFileReadStream(string path, Stream stream) { if (stream != null) { stream.Close(); } } /// /// Gets extended parameter information specific to the compression format /// being used. /// /// Name of the option being requested. /// Parameters for the option; for per-file options, /// the first parameter is typically the internal file path. /// Option value, or null to use the default behavior. /// /// This implementation does not handle any options. Subclasses may override /// this method to allow for non-default behavior. /// public virtual object GetOption(string optionName, object[] parameters) { return null; } #endregion #region IUnpackStreamContext Members /// /// Opens the archive stream for reading. /// /// The zero-based index of the archive to /// open. /// The name of the archive being opened. /// Instance of the compression engine /// doing the operations. /// A stream from which archive bytes are read, or null to cancel /// extraction of the archive. /// /// This method opens the file from the list with /// the specified index. If the archive number is outside the bounds of the /// list, this method returns null. /// If the flag is set, this method will /// seek to the start of any existing archive in the file, or to the end of /// the file if the existing file is not an archive. /// public virtual Stream OpenArchiveReadStream( int archiveNumber, string archiveName, CompressionEngine compressionEngine) { if (archiveNumber >= this.archiveFiles.Count) { return null; } string archiveFile = this.archiveFiles[archiveNumber]; Stream stream = File.Open( archiveFile, FileMode.Open, FileAccess.Read, FileShare.Read); if (this.enableOffsetOpen) { long offset = compressionEngine.FindArchiveOffset( new DuplicateStream(stream)); if (offset > 0) { stream = new OffsetStream(stream, offset); } else { stream.Seek(0, SeekOrigin.Begin); } } return stream; } /// /// Closes a stream where an archive was read. /// /// The archive number of the stream /// to close. /// The name of the archive being closed. /// The stream that was previously returned by /// and is now ready to be closed. public virtual void CloseArchiveReadStream( int archiveNumber, string archiveName, Stream stream) { if (stream != null) { stream.Close(); } } /// /// Opens a stream for writing extracted file bytes. /// /// The path of the file within the archive. /// The uncompressed size of the file to be /// extracted. /// The last write time of the file to be /// extracted. /// A stream where extracted file bytes are to be written, or null /// to skip extraction of the file and continue to the next file. /// /// This method opens a file using the following logic: /// /// If the and the mapping /// are both null, the path is treated as relative to the current directory, /// and that file is opened. /// If the is not null but the /// mapping is null, the path is treated as relative to that directory, and /// that file is opened. /// If the is null but the /// mapping is not null, the path parameter is used as a key into the mapping, /// and the resulting value is the file path that is opened, relative to the /// current directory (or it may be an absolute path). If no mapping exists, /// the file is skipped. /// If both the and the /// mapping are specified, the path parameter is used as a key into the /// mapping, and the resulting value is the file path that is opened, /// relative to the specified directory (or it may be an absolute path). /// If no mapping exists, the file is skipped. /// /// If the flag is set, the file /// is skipped if a file currently exists in the same path with an equal /// or newer write time. /// public virtual Stream OpenFileWriteStream( string path, long fileSize, DateTime lastWriteTime) { string filePath = this.TranslateFilePath(path); if (filePath == null) { return null; } FileInfo file = new FileInfo(filePath); if (file.Exists) { if (this.extractOnlyNewerFiles && lastWriteTime != DateTime.MinValue) { if (file.LastWriteTime >= lastWriteTime) { return null; } } // Clear attributes that will prevent overwriting the file. // (The final attributes will be set after the file is unpacked.) FileAttributes attributesToClear = FileAttributes.ReadOnly | FileAttributes.Hidden | FileAttributes.System; if ((file.Attributes & attributesToClear) != 0) { file.Attributes &= ~attributesToClear; } } if (!file.Directory.Exists) { file.Directory.Create(); } return File.Open( filePath, FileMode.Create, FileAccess.Write, FileShare.None); } /// /// Closes a stream where an extracted file was written. /// /// The path of the file within the archive. /// The stream that was previously returned by /// and is now ready to be closed. /// The attributes of the extracted file. /// The last write time of the file. /// /// After closing the extracted file stream, this method applies the date /// and attributes to that file. /// public virtual void CloseFileWriteStream( string path, Stream stream, FileAttributes attributes, DateTime lastWriteTime) { if (stream != null) { stream.Close(); } string filePath = this.TranslateFilePath(path); if (filePath != null) { FileInfo file = new FileInfo(filePath); if (lastWriteTime != DateTime.MinValue) { try { file.LastWriteTime = lastWriteTime; } catch (ArgumentException) { } catch (IOException) { } } try { file.Attributes = attributes; } catch (IOException) { } } } #endregion #region Private utility methods /// /// Translates an internal file path to an external file path using the /// and the mapping, according to /// rules documented in and /// . /// /// The path of the file with the archive. /// The external path of the file, or null if there is no /// valid translation. private string TranslateFilePath(string path) { string filePath; if (this.files != null) { filePath = this.files[path]; } else { this.ValidateArchivePath(path); filePath = path; } if (filePath != null) { if (this.directory != null) { filePath = Path.Combine(this.directory, filePath); } } return filePath; } private void ValidateArchivePath(string filePath) { string basePath = Path.GetFullPath(String.IsNullOrEmpty(this.directory) ? Environment.CurrentDirectory : this.directory); string path = Path.GetFullPath(Path.Combine(basePath, filePath)); if (!path.StartsWith(basePath, StringComparison.InvariantCultureIgnoreCase)) { throw new InvalidDataException("Archive cannot contain files with absolute or traversal paths."); } } #endregion } }