File: PackageArchiveReader.cs
Web Access
Project: src\src\nuget-client\src\NuGet.Core\NuGet.Packaging\NuGet.Packaging.csproj (NuGet.Packaging)
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using NuGet.Common;
using NuGet.Frameworks;
using NuGet.Packaging.Core;
using NuGet.Packaging.Signing;

namespace NuGet.Packaging
{
    /// <summary>
    /// Reads a development nupkg
    /// </summary>
    public class PackageArchiveReader : PackageReaderBase
    {
        private readonly ZipArchive _zipArchive;
        private readonly SigningSpecifications _signingSpecifications = SigningSpecifications.V1;
        private readonly IEnvironmentVariableReader _environmentVariableReader;

        /// <summary>
        /// Signature specifications.
        /// </summary>
        protected SigningSpecifications SigningSpecifications => _signingSpecifications;

        /// <summary>
        /// Stream underlying the ZipArchive. Used to do signature verification on a SignedPackageArchive.
        /// If this is null then we cannot perform signature verification.
        /// </summary>
        protected Stream? ZipReadStream { get; set; }

        /// <summary>
        /// True if the package is signed
        /// </summary>
        private bool? _isSigned;

        // For testing purposes only
        internal PackageArchiveReader(Stream stream, IEnvironmentVariableReader environmentVariableReader)
            : this(stream)
        {
            if (environmentVariableReader != null)
            {
                _environmentVariableReader = environmentVariableReader;
            }
        }

        /// <summary>
        /// Nupkg package reader
        /// </summary>
        /// <param name="stream">Nupkg data stream.</param>
        public PackageArchiveReader(Stream stream)
            : this(stream, false, DefaultFrameworkNameProvider.Instance, DefaultCompatibilityProvider.Instance)
        {
        }

        /// <summary>
        /// Nupkg package reader
        /// </summary>
        /// <param name="stream">Nupkg data stream.</param>
        /// <param name="frameworkProvider">Framework mapping provider for NuGetFramework parsing.</param>
        /// <param name="compatibilityProvider">Framework compatibility provider.</param>
        public PackageArchiveReader(Stream stream, IFrameworkNameProvider frameworkProvider, IFrameworkCompatibilityProvider compatibilityProvider)
            : this(stream, false)
        {
        }

        /// <summary>
        /// Nupkg package reader
        /// </summary>
        /// <param name="stream">Nupkg data stream.</param>
        /// <param name="leaveStreamOpen">If true the nupkg stream will not be closed by the zip reader.</param>
        public PackageArchiveReader(Stream stream, bool leaveStreamOpen)
            : this(new ZipArchive(stream, ZipArchiveMode.Read, leaveStreamOpen), DefaultFrameworkNameProvider.Instance, DefaultCompatibilityProvider.Instance)
        {
            ZipReadStream = stream;
        }

        /// <summary>
        /// Nupkg package reader
        /// </summary>
        /// <param name="stream">Nupkg data stream.</param>
        /// <param name="leaveStreamOpen">leave nupkg stream open</param>
        /// <param name="frameworkProvider">Framework mapping provider for NuGetFramework parsing.</param>
        /// <param name="compatibilityProvider">Framework compatibility provider.</param>
        public PackageArchiveReader(Stream stream, bool leaveStreamOpen, IFrameworkNameProvider frameworkProvider, IFrameworkCompatibilityProvider compatibilityProvider)
            : this(new ZipArchive(stream, ZipArchiveMode.Read, leaveStreamOpen), frameworkProvider, compatibilityProvider)
        {
            ZipReadStream = stream;
        }

        /// <summary>
        /// Nupkg package reader
        /// </summary>
        /// <param name="zipArchive">ZipArchive containing the nupkg data.</param>
        public PackageArchiveReader(ZipArchive zipArchive)
            : this(zipArchive, DefaultFrameworkNameProvider.Instance, DefaultCompatibilityProvider.Instance)
        {
        }

        /// <summary>
        /// Nupkg package reader
        /// </summary>
        /// <param name="zipArchive">ZipArchive containing the nupkg data.</param>
        /// <param name="frameworkProvider">Framework mapping provider for NuGetFramework parsing.</param>
        /// <param name="compatibilityProvider">Framework compatibility provider.</param>
        public PackageArchiveReader(ZipArchive zipArchive, IFrameworkNameProvider frameworkProvider, IFrameworkCompatibilityProvider compatibilityProvider)
            : base(frameworkProvider, compatibilityProvider)
        {
            _environmentVariableReader = EnvironmentVariableWrapper.Instance;
            _zipArchive = zipArchive ?? throw new ArgumentNullException(nameof(zipArchive));
        }

        public PackageArchiveReader(string filePath, IFrameworkNameProvider? frameworkProvider = null, IFrameworkCompatibilityProvider? compatibilityProvider = null)
            : base(frameworkProvider ?? DefaultFrameworkNameProvider.Instance, compatibilityProvider ?? DefaultCompatibilityProvider.Instance)
        {
            _environmentVariableReader = EnvironmentVariableWrapper.Instance;
            if (filePath == null)
            {
                throw new ArgumentNullException(nameof(filePath));
            }

            // Since this constructor owns the stream, the responsibility falls here to dispose the stream of an
            // invalid .zip archive. If this constructor succeeds, the disposal of the stream is handled by the
            // disposal of this instance.
            Stream? stream = null;
            try
            {
                stream = File.OpenRead(filePath);
                ZipReadStream = stream;
                _zipArchive = new ZipArchive(stream, ZipArchiveMode.Read);
            }
            catch (Exception ex)
            {
                stream?.Dispose();
                throw new InvalidDataException(string.Format(CultureInfo.CurrentCulture, Strings.InvalidPackageNupkg, filePath), ex);
            }
        }

        public override IEnumerable<string> GetFiles()
        {
            return _zipArchive.GetFiles();
        }

        public override IEnumerable<string> GetFiles(string folder)
        {
            return GetFiles().Where(f => f.StartsWith(folder + "/", StringComparison.OrdinalIgnoreCase));
        }

        public override Stream GetStream(string path)
        {
            path = path.Replace(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
            if (string.IsNullOrEmpty(path))
            {
                throw new ArgumentException(Strings.ArgumentCannotBeNullOrEmpty, nameof(path));
            }

            return _zipArchive.OpenFile(path);
        }

        /// <summary>
        /// Asynchronously copies a package to the specified destination file path.
        /// </summary>
        /// <param name="nupkgFilePath">The destination file path.</param>
        /// <param name="cancellationToken">A cancellation token.</param>
        /// <returns>A task that represents the asynchronous operation.
        /// The task result (<see cref="Task{TResult}.Result" />) returns a <see cref="string" />.</returns>
        /// <exception cref="ArgumentException">Thrown if <paramref name="nupkgFilePath" />
        /// is either <see langword="null" /> or an empty string.</exception>
        /// <exception cref="OperationCanceledException">Thrown if <paramref name="cancellationToken" />
        /// is cancelled.</exception>
        public override async Task<string> CopyNupkgAsync(
            string nupkgFilePath,
            CancellationToken cancellationToken)
        {
            if (string.IsNullOrEmpty(nupkgFilePath))
            {
                throw new ArgumentException(Strings.ArgumentCannotBeNullOrEmpty, nameof(nupkgFilePath));
            }

            cancellationToken.ThrowIfCancellationRequested();

            ThrowIfZipReadStreamIsNull();

            ZipReadStream.Seek(offset: 0, origin: SeekOrigin.Begin);

            using (var destination = File.OpenWrite(nupkgFilePath))
            {
#if NETCOREAPP2_0_OR_GREATER
                await ZipReadStream.CopyToAsync(destination, cancellationToken);
#else
                const int BufferSize = 8192;
                await ZipReadStream.CopyToAsync(destination, BufferSize, cancellationToken);
#endif
            }

            return nupkgFilePath;
        }

        protected override void Dispose(bool disposing)
        {
            if (disposing)
            {
                _zipArchive.Dispose();
            }
        }

        /// <summary>
        /// This class literally just exists so CopyToFile gets a file size
        /// </summary>
        private sealed class SizedArchiveEntryStream : Stream
        {
            private readonly Stream _inner;

            private readonly long _size;

            private bool _isDisposed;

            public SizedArchiveEntryStream(Stream inner, long size)
            {
                _inner = inner;
                _size = size;
            }

            public override long Length { get => _size; }

            public override bool CanRead => _inner.CanRead;

            public override bool CanSeek => _inner.CanSeek;

            public override bool CanWrite => _inner.CanWrite;

            public override long Position { get => _inner.Position; set => _inner.Position = value; }

            public override void Flush()
            {
                _inner.Flush();
            }

            public override int Read(byte[] buffer, int offset, int count)
            {
                return _inner.Read(buffer, offset, count);
            }

            public override long Seek(long offset, SeekOrigin origin)
            {
                return _inner.Seek(offset, origin);
            }

            public override void SetLength(long value)
            {
                _inner.SetLength(value);
            }

            public override void Write(byte[] buffer, int offset, int count)
            {
                _inner.Write(buffer, offset, count);
            }

            protected override void Dispose(bool disposing)
            {
                if (!_isDisposed)
                {
                    if (disposing)
                    {
                        _inner.Dispose();
                    }

                    _isDisposed = true;
                }
            }
        }

        public override IEnumerable<string> CopyFiles(
            string destination,
            IEnumerable<string> packageFiles,
            ExtractPackageFileDelegate extractFile,
            ILogger logger,
            CancellationToken token)
        {
            var filesCopied = new List<string>();
            var packageIdentity = GetIdentity();

            foreach (var packageFile in packageFiles)
            {
                token.ThrowIfCancellationRequested();

                var entry = GetEntry(packageFile);

                var packageFileName = entry.FullName;
                // An entry in a ZipArchive could start with a '/' based on how it is zipped
                // Remove it if present
                if (packageFileName.StartsWith("/", StringComparison.Ordinal))
                {
                    packageFileName = packageFileName.Substring(1);
                }

                // ZipArchive always has forward slashes in them. By replacing them with DirectorySeparatorChar;
                // in windows, we get the windows-style path
                var normalizedPath = Uri.UnescapeDataString(packageFileName.Replace('/', Path.DirectorySeparatorChar));

                destination = NormalizeDirectoryPath(destination);
                ValidatePackageEntry(destination, normalizedPath, packageIdentity);

                var targetFilePath = Path.Combine(destination, normalizedPath);

                using (var stream = entry.Open())
                using (var sizedStream = new SizedArchiveEntryStream(stream, entry.Length))
                {
                    string? copiedFile = extractFile(packageFileName, targetFilePath, sizedStream);
                    if (copiedFile != null)
                    {
                        entry.UpdateFileTimeFromEntry(copiedFile, logger);

                        filesCopied.Add(copiedFile);
                    }
                }
            }

            return filesCopied;
        }

        public string ExtractFile(string packageFile, string targetFilePath, ILogger logger)
        {
            var entry = GetEntry(packageFile);
            var copiedFile = entry.SaveAsFile(targetFilePath, logger);
            return copiedFile;
        }

        public ZipArchiveEntry GetEntry(string packageFile)
        {
            return _zipArchive.LookupEntry(packageFile);
        }

        public IEnumerable<ZipFilePair> EnumeratePackageEntries(IEnumerable<string> packageFiles, string packageDirectory)
        {
            foreach (var packageFile in packageFiles)
            {
                var packageFileFullPath = Path.Combine(packageDirectory, packageFile);
                var entry = GetEntry(packageFile);
                yield return new ZipFilePair(packageFileFullPath, entry);
            }
        }

        /// <summary>
        /// Validate all files in package are not traversed outside of the expected extraction path.
        /// Eg: file entry like ../../foo.dll can get outside of the expected extraction path.
        /// </summary>
        public async Task ValidatePackageEntriesAsync(CancellationToken token)
        {
            try
            {
                var files = await GetFilesAsync(token);
                var packageIdentity = await GetIdentityAsync(token);

                // This dummy destination is used to check if the file in package get outside of the extractionPath
                var dummyDestination = NuGetEnvironment.GetFolderPath(NuGetFolderPath.NuGetHome);

                dummyDestination = NormalizeDirectoryPath(dummyDestination);

                ValidatePackageEntries(dummyDestination, files, packageIdentity);
            }
            catch (UnsafePackageEntryException)
            {
                throw;
            }
            catch (Exception e)
            {
                throw new PackagingException(string.Format(
                    CultureInfo.CurrentCulture,
                    Strings.ErrorUnableCheckPackageEntries), e);
            }
        }

        public override async Task<PrimarySignature?> GetPrimarySignatureAsync(CancellationToken token)
        {
            token.ThrowIfCancellationRequested();

            ThrowIfZipReadStreamIsNull();

            PrimarySignature? signature = null;

            if (await IsSignedAsync(token))
            {
                using (var bufferedStream = new ReadOnlyBufferedStream(ZipReadStream, leaveOpen: true))
                using (var reader = new BinaryReader(bufferedStream, new UTF8Encoding(), leaveOpen: true))
                using (var stream = SignedPackageArchiveUtility.OpenPackageSignatureFileStream(reader))
                {
                    signature = PrimarySignature.Load(stream);
                }
            }

            return signature;
        }

        public override Task<bool> IsSignedAsync(CancellationToken token)
        {
            token.ThrowIfCancellationRequested();

            ThrowIfZipReadStreamIsNull();

            if (!_isSigned.HasValue)
            {
                _isSigned = false;

                using (var zip = new ZipArchive(ZipReadStream, ZipArchiveMode.Read, leaveOpen: true))
                {
                    var signatureEntry = zip.GetEntry(SigningSpecifications.SignaturePath);

                    if (signatureEntry != null &&
                       string.Equals(signatureEntry.Name, SigningSpecifications.SignaturePath, StringComparison.Ordinal))
                    {
                        _isSigned = true;
                    }
                }
            }

            return Task.FromResult(_isSigned.Value);
        }

        public override async Task ValidateIntegrityAsync(SignatureContent signatureContent, CancellationToken token)
        {
            token.ThrowIfCancellationRequested();

            if (signatureContent == null)
            {
                throw new ArgumentNullException(nameof(signatureContent));
            }

            ThrowIfZipReadStreamIsNull();

            if (!await IsSignedAsync(token))
            {
                throw new SignatureException(Strings.SignedPackageNotSignedOnVerify);
            }

            using (var bufferedStream = new ReadOnlyBufferedStream(ZipReadStream, leaveOpen: true))
            using (var reader = new BinaryReader(bufferedStream, new UTF8Encoding(), leaveOpen: true))
            using (var hashAlgorithm = signatureContent.HashAlgorithm.GetHashProvider())
            {
                var expectedHash = Convert.FromBase64String(signatureContent.HashValue);

                if (!SignedPackageArchiveUtility.VerifySignedPackageIntegrity(reader, hashAlgorithm, expectedHash))
                {
                    throw new SignatureException(NuGetLogCode.NU3008, Strings.SignaturePackageIntegrityFailure, GetIdentity());
                }
            }
        }

        public override string GetContentHash(CancellationToken token, Func<string>? GetUnsignedPackageHash = null)
        {
            // Try to get the content hash for signed packages
            var contentHash = GetContentHashForSignedPackage(token);

            if (string.IsNullOrEmpty(contentHash))
            {
                // The package is unsigned, try to read the existing sha512 file
                if (GetUnsignedPackageHash != null)
                {
                    var packageHash = GetUnsignedPackageHash();

                    if (!string.IsNullOrEmpty(packageHash))
                    {
                        return packageHash;
                    }
                }

                ThrowIfZipReadStreamIsNull();

                ZipReadStream.Seek(offset: 0, origin: SeekOrigin.Begin);

                contentHash = Convert.ToBase64String(new CryptoHashProvider("SHA512").CalculateHash(ZipReadStream));
            }

            return contentHash!;
        }

        public override Task<byte[]> GetArchiveHashAsync(HashAlgorithmName hashAlgorithmName, CancellationToken token)
        {
            token.ThrowIfCancellationRequested();

            ThrowIfZipReadStreamIsNull();

            ZipReadStream.Seek(offset: 0, origin: SeekOrigin.Begin);

            using (var hashAlgorithm = hashAlgorithmName.GetHashProvider())
            {
                var hash = hashAlgorithm.ComputeHash(ZipReadStream, leaveStreamOpen: true);

                return Task.FromResult(hash);
            }
        }

        public override bool CanVerifySignedPackages(SignedPackageVerifierSettings verifierSettings)
        {
            // Mono support has been deprioritized, so verification on Mono is not enabled, tracking issue: https://github.com/NuGet/Home/issues/9027
            if (RuntimeEnvironmentHelper.IsMono)
            {
                return false;
            }
            else if (RuntimeEnvironmentHelper.IsLinux || RuntimeEnvironmentHelper.IsMacOSX)
            {
                // Please note: Linux/MAC case sensitive for env var name.
                string? signVerifyEnvVariable = _environmentVariableReader.GetEnvironmentVariable(
                    EnvironmentVariableConstants.DotNetNuGetSignatureVerification);

                bool canVerify = false;

                if (!string.IsNullOrEmpty(signVerifyEnvVariable))
                {
                    if (string.Equals(bool.TrueString, signVerifyEnvVariable, StringComparison.OrdinalIgnoreCase))
                    {
                        canVerify = true;
                    }
                    else if (string.Equals(bool.FalseString, signVerifyEnvVariable, StringComparison.OrdinalIgnoreCase))
                    {
                        canVerify = false;
                    }
                }

                return canVerify;
            }
            else
            {
                return true;
            }
        }

        [MemberNotNull(nameof(ZipReadStream))]
        protected void ThrowIfZipReadStreamIsNull()
        {
            if (ZipReadStream == null)
            {
                throw new SignatureException(Strings.SignedPackageUnableToAccessSignature);
            }
        }

        private string? GetContentHashForSignedPackage(CancellationToken token)
        {
            token.ThrowIfCancellationRequested();

            if (ZipReadStream == null)
            {
                return null;
            }

            using (var zip = new ZipArchive(ZipReadStream, ZipArchiveMode.Read, leaveOpen: true))
            {
                var signatureEntry = zip.GetEntry(SigningSpecifications.SignaturePath);

                if (signatureEntry == null ||
                    !string.Equals(signatureEntry.Name, SigningSpecifications.SignaturePath, StringComparison.Ordinal))
                {
                    return null;
                }
            }

            using (var bufferedStream = new ReadOnlyBufferedStream(ZipReadStream, leaveOpen: true))
            using (var reader = new BinaryReader(bufferedStream, new UTF8Encoding(), leaveOpen: true))
            {
                return SignedPackageArchiveUtility.GetPackageContentHash(reader);
            }
        }
    }
}