Skip to content

Instantly share code, notes, and snippets.

@jberezanski
Created November 16, 2014 23:03
Show Gist options
  • Save jberezanski/3169e7cd6b890ae868a9 to your computer and use it in GitHub Desktop.
Save jberezanski/3169e7cd6b890ae868a9 to your computer and use it in GitHub Desktop.
Chocolatey package validation
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