Created
November 16, 2014 23:03
-
-
Save jberezanski/3169e7cd6b890ae868a9 to your computer and use it in GitHub Desktop.
Chocolatey package validation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.Collections.Generic; | |
using System.Collections.ObjectModel; | |
using System.IO; | |
using System.IO.Compression; | |
using System.Linq; | |
using System.Management.Automation; | |
using System.Text; | |
using System.Xml; | |
using System.Xml.Linq; | |
using Xunit; | |
namespace Chocolatey | |
{ | |
public sealed class ChocolateyValidator | |
{ | |
private static readonly ICollection<string> RequiredFiles = | |
Array.AsReadOnly( | |
new[] | |
{ | |
@"[Content_Types].xml", | |
@"_rels/.rels", | |
@"tools/chocolateyInstall.ps1" | |
}); | |
private static readonly ICollection<string> ChocolateyScriptFiles = | |
Array.AsReadOnly( | |
new[] | |
{ | |
@"tools/chocolateyInstall.ps1", | |
@"tools/chocolateyUninstall.ps1" | |
}); | |
private static readonly byte[] Utf8Bom = { 0xEF, 0xBB, 0xBF }; | |
public ICollection<PackageIssue> Validate(Stream nupkgStream, string packageId) | |
{ | |
var issues = new List<PackageIssue>(); | |
try | |
{ | |
var zip = new ZipArchive(nupkgStream, ZipArchiveMode.Read, true); | |
using (zip) | |
{ | |
var filesDictionary = zip.Entries.ToDictionary(e => e.FullName, e => e, StringComparer.OrdinalIgnoreCase); | |
issues.AddRange( | |
RequiredFiles | |
.Where(requiredFile => !filesDictionary.ContainsKey(requiredFile)) | |
.Select(requiredFile => new RequiredFileMissing { FileName = requiredFile })); | |
var fileInContentDirectory = filesDictionary.Keys.FirstOrDefault(s => s.StartsWith("content/", StringComparison.OrdinalIgnoreCase)); | |
if (fileInContentDirectory != null) | |
{ | |
issues.Add(new PackageHasFileInContentDirectory { FileName = fileInContentDirectory }); | |
} | |
ZipArchiveEntry entry; | |
var nuspecFileName = packageId + ".nuspec"; | |
if (!filesDictionary.TryGetValue(nuspecFileName, out entry)) | |
{ | |
issues.Add(new RequiredFileMissing { FileName = nuspecFileName }); | |
} | |
else | |
{ | |
issues.AddRange(this.ValidateNuspec(entry, packageId)); | |
} | |
foreach (var chocolateyScriptFile in ChocolateyScriptFiles) | |
{ | |
if (filesDictionary.TryGetValue(chocolateyScriptFile, out entry)) | |
{ | |
issues.AddRange(this.ValidateChocolateyPs1(entry)); | |
} | |
} | |
} | |
} | |
catch (InvalidDataException x) | |
{ | |
issues.Add(new PackageStructureError { Exception = x }); | |
} | |
return issues; | |
} | |
private IEnumerable<PackageIssue> ValidateChocolateyPs1(ZipArchiveEntry entry) | |
{ | |
var issues = new Collection<PackageFileIssue>(); | |
try | |
{ | |
var stream = entry.Open(); | |
using (stream) | |
{ | |
this.ValidateUtf8Bom(stream, entry.FullName, issues); | |
} | |
string script; | |
stream = entry.Open(); | |
using (stream) | |
{ | |
using (var reader = new StreamReader(stream, Encoding.UTF8)) | |
{ | |
script = reader.ReadToEnd(); | |
} | |
} | |
try | |
{ | |
ScriptBlock.Create(script); | |
} | |
catch (Exception x) | |
{ | |
issues.Add(new PowerShellScriptSyntaxError { Exception = x, FileName = entry.FullName }); | |
} | |
} | |
catch (InvalidDataException x) | |
{ | |
issues.Add(new PackageStructureError { Exception = x, FileName = entry.FullName }); | |
} | |
return issues; | |
} | |
private IEnumerable<PackageFileIssue> ValidateNuspec(ZipArchiveEntry entry, string packageId) | |
{ | |
var issues = new Collection<PackageFileIssue>(); | |
try | |
{ | |
var stream = entry.Open(); | |
using (stream) | |
{ | |
this.ValidateUtf8Bom(stream, entry.FullName, issues); | |
} | |
XDocument doc; | |
stream = entry.Open(); | |
using (stream) | |
{ | |
doc = XDocument.Load(stream, LoadOptions.SetLineInfo); | |
} | |
if (doc.Root == null) | |
{ | |
issues.Add(new PackageMetadataIncomplete { MissingElement = "<Root>", FileName = entry.FullName }); | |
return issues; | |
} | |
var ns = doc.Root.Name.Namespace; | |
var idElementQuery = from m in doc.Root.Elements(ns + "metadata") | |
from i in m.Elements(ns + "id") | |
select i; | |
var idElement = idElementQuery.FirstOrDefault(); | |
if (idElement == null) | |
{ | |
issues.Add(new PackageMetadataIncomplete { MissingElement = "id", FileName = entry.FullName }); | |
} | |
else | |
{ | |
var packageIdInNuspec = idElement.Value; | |
if (!string.Equals(packageIdInNuspec, packageId, StringComparison.Ordinal)) | |
{ | |
issues.Add(new PackageIdMismatch { PackageIdInNuspec = packageIdInNuspec, FileName = entry.FullName }); | |
} | |
if (packageIdInNuspec != packageIdInNuspec.ToLowerInvariant()) | |
{ | |
issues.Add(new PackageIdShouldBeLowercase { FileName = entry.FullName }); | |
} | |
} | |
} | |
catch (XmlException x) | |
{ | |
issues.Add(new NuspecSyntaxError { Exception = x, FileName = entry.FullName }); | |
} | |
catch (InvalidDataException x) | |
{ | |
issues.Add(new PackageStructureError { Exception = x, FileName = entry.FullName }); | |
} | |
return issues; | |
} | |
private void ValidateUtf8Bom(Stream stream, string fileName, ICollection<PackageFileIssue> issues) | |
{ | |
var bytes = new byte[Utf8Bom.Length]; | |
var n = stream.Read(bytes, 0, bytes.Length); | |
if (n == bytes.Length) | |
{ | |
if (bytes.Where((t, i) => t != Utf8Bom[i]).Any()) | |
{ | |
issues.Add(new FileDoesNotStartWithUtf8Bom { FileName = fileName }); | |
return; | |
} | |
} | |
n = stream.Read(bytes, 0, bytes.Length); | |
if (n == bytes.Length) | |
{ | |
if (!bytes.Where((t, i) => t != Utf8Bom[i]).Any()) | |
{ | |
issues.Add(new FileContainsDuplicateUtf8Bom { FileName = fileName }); | |
} | |
} | |
} | |
} | |
public sealed class ChocolateyValidatorTests | |
{ | |
[Fact] | |
public void Can_inspect_mostly_correct_package() | |
{ | |
var packageStream = this.GetType().Assembly.GetManifestResourceStream(this.GetType(), "rdcman.2.2.0.20141107.nupkg"); | |
var issues = new ChocolateyValidator().Validate(packageStream, "rdcman"); | |
Assert.Equal(1, issues.Count); | |
Assert.IsType<FileDoesNotStartWithUtf8Bom>(issues.Single()); | |
Assert.Equal("rdcman.nuspec", issues.OfType<FileDoesNotStartWithUtf8Bom>().Single().FileName); | |
} | |
[Fact] | |
public void Will_detect_duplicate_BOM() | |
{ | |
var packageStream = this.GetType().Assembly.GetManifestResourceStream(this.GetType(), "rdcman.2.2.0.20141106.nupkg"); | |
var issues = new ChocolateyValidator().Validate(packageStream, "rdcman"); | |
Assert.Equal(2, issues.Count); | |
Assert.Equal(1, issues.OfType<FileDoesNotStartWithUtf8Bom>().Count()); | |
Assert.Equal("rdcman.nuspec", issues.OfType<FileDoesNotStartWithUtf8Bom>().Single().FileName); | |
Assert.Equal(1, issues.OfType<FileContainsDuplicateUtf8Bom>().Count()); | |
Assert.Equal("tools/chocolateyInstall.ps1", issues.OfType<FileContainsDuplicateUtf8Bom>().Single().FileName); | |
} | |
} | |
public abstract class PackageIssue | |
{ | |
public Exception Exception { get; set; } | |
} | |
public abstract class PackageFileIssue : PackageIssue | |
{ | |
public string FileName { get; set; } | |
} | |
public sealed class PackageStructureError : PackageFileIssue | |
{ | |
} | |
public sealed class PackageHasFileInContentDirectory : PackageFileIssue | |
{ | |
} | |
public sealed class NuspecSyntaxError : PackageFileIssue | |
{ | |
} | |
public abstract class PackageMetadataIssue : PackageFileIssue | |
{ | |
} | |
public sealed class PackageMetadataIncomplete : PackageMetadataIssue | |
{ | |
public string MissingElement { get; set; } | |
} | |
public sealed class PackageIdMismatch : PackageMetadataIssue | |
{ | |
public string PackageIdInNuspec { get; set; } | |
} | |
public sealed class PackageIdShouldBeLowercase : PackageMetadataIssue | |
{ | |
} | |
public sealed class FileDoesNotStartWithUtf8Bom : PackageFileIssue | |
{ | |
} | |
public sealed class FileContainsDuplicateUtf8Bom : PackageFileIssue | |
{ | |
} | |
public sealed class PowerShellScriptSyntaxError : PackageFileIssue | |
{ | |
} | |
public sealed class RequiredFileMissing : PackageFileIssue | |
{ | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment