From db4f235fb9f1e77c77eb844fed8420a75ca3073a Mon Sep 17 00:00:00 2001 From: Garrett Mears Date: Sun, 24 Apr 2016 22:25:58 +0100 Subject: [PATCH] Add validation when packaging a pass using the PassValidator class --- .../Exception/PassInvalidException.php | 29 +++ src/Passbook/Pass/Localization.php | 2 +- src/Passbook/Pass/LocalizationInterface.php | 6 +- src/Passbook/PassFactory.php | 216 +++++++++++++----- src/Passbook/PassInterface.php | 13 ++ src/Passbook/PassValidator.php | 22 +- src/Passbook/PassValidatorInterface.php | 24 ++ .../Exception/PassInvalidExceptionTest.php | 26 +++ tests/Passbook/Tests/PassFactoryTest.php | 34 +++ 9 files changed, 297 insertions(+), 75 deletions(-) create mode 100644 src/Passbook/Exception/PassInvalidException.php create mode 100644 src/Passbook/PassValidatorInterface.php create mode 100644 tests/Passbook/Tests/Exception/PassInvalidExceptionTest.php diff --git a/src/Passbook/Exception/PassInvalidException.php b/src/Passbook/Exception/PassInvalidException.php new file mode 100644 index 0000000..5112663 --- /dev/null +++ b/src/Passbook/Exception/PassInvalidException.php @@ -0,0 +1,29 @@ +errors = $errors ? $errors : array(); + } + + /** + * Returns the errors with the pass. + * + * @return string[] + */ + public function getErrors() + { + return $this->errors; + } +} diff --git a/src/Passbook/Pass/Localization.php b/src/Passbook/Pass/Localization.php index 353d19c..3365824 100644 --- a/src/Passbook/Pass/Localization.php +++ b/src/Passbook/Pass/Localization.php @@ -102,7 +102,7 @@ public function getStringsFileOutput() /** * {@inheritdoc} */ - public function addImage(ImageInterface $image) + public function addImage(Image $image) { $this->images[] = $image; diff --git a/src/Passbook/Pass/LocalizationInterface.php b/src/Passbook/Pass/LocalizationInterface.php index f5af0b1..c345a83 100644 --- a/src/Passbook/Pass/LocalizationInterface.php +++ b/src/Passbook/Pass/LocalizationInterface.php @@ -68,14 +68,14 @@ public function getStrings(); public function getStringsFileOutput(); /** - * @param ImageInterface $image + * @param Image $image * * @return LocalizationInterface */ - public function addImage(ImageInterface $image); + public function addImage(Image $image); /** - * @return ImageInterface[] + * @return Image[] */ public function getImages(); } diff --git a/src/Passbook/PassFactory.php b/src/Passbook/PassFactory.php index 0859434..148ff83 100644 --- a/src/Passbook/PassFactory.php +++ b/src/Passbook/PassFactory.php @@ -16,6 +16,7 @@ use Passbook\Certificate\P12; use Passbook\Certificate\WWDR; use Passbook\Exception\FileException; +use Passbook\Exception\PassInvalidException; use Passbook\Pass\Image; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; @@ -83,6 +84,11 @@ class PassFactory */ protected $skipSignature; + /** + * @var PassValidatorInterface + */ + private $passValidator; + /** * Pass file extension * @@ -96,9 +102,13 @@ public function __construct($passTypeIdentifier, $teamIdentifier, $organizationN $this->passTypeIdentifier = $passTypeIdentifier; $this->teamIdentifier = $teamIdentifier; $this->organizationName = $organizationName; + // Create certificate objects $this->p12 = new P12($p12File, $p12Pass); $this->wwdr = new WWDR($wwdrFile); + + // By default use the PassValidator + $this->passValidator = new PassValidator(); } /** @@ -125,6 +135,16 @@ public function getOutputPath() return $this->outputPath; } + /** + * The output path with a directory separator on the end. + * + * @return string + */ + public function getNormalizedOutputPath() + { + return rtrim($this->outputPath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + } + /** * Set overwrite * @@ -156,6 +176,7 @@ public function isOverwrite() * be used for testing. * * @param boolean + * * @return $this */ public function setSkipSignature($skipSignature) @@ -166,7 +187,8 @@ public function setSkipSignature($skipSignature) } /** - * Get overwrite + * Get skip signature + * * @return boolean */ public function getSkipSignature() @@ -174,6 +196,30 @@ public function getSkipSignature() return $this->skipSignature; } + /** + * Set an implementation of PassValidatorInterface to validate the pass + * before packaging. When set to null, no validation is performed when + * packaging the pass. + * + * @param PassValidatorInterface|null $passValidator + * + * @return $this + */ + public function setPassValidator(PassValidatorInterface $passValidator = null) + { + $this->passValidator = $passValidator; + + return $this; + } + + /** + * @return PassValidatorInterface + */ + public function getPassValidator() + { + return $this->passValidator; + } + /** * Serialize pass * @@ -203,80 +249,31 @@ public function package(PassInterface $pass, $passName = '') $this->populateRequiredInformation($pass); - // Serialize pass - $json = self::serialize($pass); - - $outputPath = rtrim($this->getOutputPath(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; - $passDir = $outputPath . $this->getPassName($passName, $pass) . DIRECTORY_SEPARATOR; - $passDirExists = file_exists($passDir); - if ($passDirExists && !$this->isOverwrite()) { - throw new FileException("Temporary pass directory already exists"); - } elseif (!$passDirExists && !mkdir($passDir, 0777, true)) { - throw new FileException("Couldn't create temporary pass directory"); + if ($this->passValidator) { + if (!$this->passValidator->validate($pass)){ + throw new PassInvalidException($this->passValidator->getErrors()); + }; } - // Pass.json - $passJSONFile = $passDir . 'pass.json'; - file_put_contents($passJSONFile, $json); + $passDir = $this->preparePassDirectory($pass); + + // Serialize pass + file_put_contents($passDir . 'pass.json', self::serialize($pass)); // Images - /** @var Image $image */ - foreach ($pass->getImages() as $image) { - $fileName = $passDir . $image->getContext(); - if ($image->isRetina()) { - $fileName .= '@2x'; - } - $fileName .= '.' . $image->getExtension(); - copy($image->getPathname(), $fileName); - } + $this->prepareImages($pass, $passDir); // Localizations - foreach ($pass->getLocalizations() as $localization) { - // Create dir (LANGUAGE.lproj) - $localizationDir = $passDir . $localization->getLanguage() . '.lproj' . DIRECTORY_SEPARATOR; - mkdir($localizationDir, 0777, true); - - // pass.strings File (Format: "token" = "value") - $localizationStringsFile = $localizationDir . 'pass.strings'; - file_put_contents($localizationStringsFile, $localization->getStringsFileOutput()); - - // Localization images - foreach ($localization->getImages() as $image) { - $fileName = $localizationDir . $image->getContext(); - if ($image->isRetina()) { - $fileName .= '@2x'; - } - $fileName .= '.' . $image->getExtension(); - copy($image->getPathname(), $fileName); - } - } + $this->prepareLocalizations($pass, $passDir); // Manifest.json - recursive, also add files in sub directories - $manifestJSONFile = $passDir . 'manifest.json'; - $manifest = array(); - $files = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($passDir), - RecursiveIteratorIterator::SELF_FIRST - ); - foreach ($files as $file) { - // Ignore "." and ".." folders - if (in_array(substr($file, strrpos($file, '/') + 1), array('.', '..'))) { - continue; - } - // - $filePath = realpath($file); - if (is_file($filePath) === true) { - $relativePathName = str_replace($passDir, '', $file->getPathname()); - $manifest[$relativePathName] = sha1_file($filePath); - } - } - file_put_contents($manifestJSONFile, $this->jsonEncode($manifest)); + $manifestJSONFile = $this->prepareManifest($passDir); // Signature $this->sign($passDir, $manifestJSONFile); // Zip pass - $zipFile = $outputPath . $this->getPassName($passName, $pass) . self::PASS_EXTENSION; + $zipFile = $this->getNormalizedOutputPath() . $this->getPassName($passName, $pass) . self::PASS_EXTENSION; $this->zip($passDir, $zipFile); // Remove temporary pass directory @@ -311,7 +308,7 @@ private function sign($passDir, $manifestJSONFile) $this->wwdr->getRealPath() ); // Get signature content - $signature = @file_get_contents($signatureFile); + $signature = file_get_contents($signatureFile); // Check signature content if (!$signature) { throw new FileException("Couldn't read signature file."); @@ -432,4 +429,97 @@ public function getPassName($passName, PassInterface $pass) return strlen($passNameSanitised) != 0 ? $passNameSanitised : $pass->getSerialNumber(); } + /** + * @param $passDir + * + * @return string + */ + private function prepareManifest($passDir) + { + $manifestJSONFile = $passDir . 'manifest.json'; + $manifest = array(); + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($passDir), + RecursiveIteratorIterator::SELF_FIRST + ); + foreach ($files as $file) { + // Ignore "." and ".." folders + if (in_array(substr($file, strrpos($file, '/') + 1), array('.', '..'))) { + continue; + } + // + $filePath = realpath($file); + if (is_file($filePath) === true) { + $relativePathName = str_replace($passDir, '', $file->getPathname()); + $manifest[$relativePathName] = sha1_file($filePath); + } + } + file_put_contents($manifestJSONFile, $this->jsonEncode($manifest)); + + return $manifestJSONFile; + } + + /** + * @param PassInterface $pass + * + * @return string + */ + private function preparePassDirectory(PassInterface $pass) + { + $passDir = $this->getNormalizedOutputPath() . $pass->getSerialNumber() . DIRECTORY_SEPARATOR; + $passDirExists = file_exists($passDir); + if ($passDirExists && !$this->isOverwrite()) { + throw new FileException("Temporary pass directory already exists"); + } elseif (!$passDirExists && !mkdir($passDir, 0777, true)) { + throw new FileException("Couldn't create temporary pass directory"); + } + + return $passDir; + } + + /** + * @param PassInterface $pass + * @param $passDir + */ + private function prepareImages(PassInterface $pass, $passDir) + { + /** @var Image $image */ + foreach ($pass->getImages() as $image) { + $fileName = $passDir . $image->getContext(); + if ($image->isRetina()) { + $fileName .= '@2x'; + } + $fileName .= '.' . $image->getExtension(); + copy($image->getPathname(), $fileName); + } + } + + /** + * @param PassInterface $pass + * @param $passDir + */ + private function prepareLocalizations(PassInterface $pass, $passDir) + { + foreach ($pass->getLocalizations() as $localization) { + // Create dir (LANGUAGE.lproj) + $localizationDir = $passDir . $localization->getLanguage() . '.lproj' . DIRECTORY_SEPARATOR; + mkdir($localizationDir, 0777, true); + + // pass.strings File (Format: "token" = "value") + $localizationStringsFile = $localizationDir . 'pass.strings'; + file_put_contents($localizationStringsFile, $localization->getStringsFileOutput()); + + // Localization images + foreach ($localization->getImages() as $image) { + $fileName = $localizationDir . $image->getContext(); + if ($image->isRetina()) { + $fileName .= '@2x'; + } + $fileName .= '.' . $image->getExtension(); + copy($image->getPathname(), $fileName); + } + } + } + + } diff --git a/src/Passbook/PassInterface.php b/src/Passbook/PassInterface.php index 4afc915..bd111c1 100644 --- a/src/Passbook/PassInterface.php +++ b/src/Passbook/PassInterface.php @@ -156,6 +156,8 @@ public function setBarcode(BarcodeInterface $barcode); public function getBarcode(); /** + * @param BarcodeInterface $barcode - barcode to add to the pass + * * @return $this */ public function addBarcode(BarcodeInterface $barcode); @@ -276,4 +278,15 @@ public function addLocalization(LocalizationInterface $localization); * @return LocalizationInterface[] */ public function getLocalizations(); + + /** + * {@inheritdoc} + */ + public function setAppLaunchURL($appLaunchURL); + + /** + * {@inheritdoc} + */ + public function getAppLaunchURL(); + } diff --git a/src/Passbook/PassValidator.php b/src/Passbook/PassValidator.php index ed590f5..08c3c33 100644 --- a/src/Passbook/PassValidator.php +++ b/src/Passbook/PassValidator.php @@ -16,7 +16,7 @@ * difficult to identify. This class aims to help identify and prevent these * issues. */ -class PassValidator +class PassValidator implements PassValidatorInterface { private $errors; @@ -44,7 +44,10 @@ class PassValidator const ASSOCIATED_STORE_IDENTIFIER_REQUIRED = 'appLaunchURL is required when associatedStoreIdentifiers is present'; const IMAGE_TYPE_INVALID = 'image files must be PNG format'; - public function validate(Pass $pass) + /** + * {@inheritdoc} + */ + public function validate(PassInterface $pass) { $this->errors = array(); @@ -60,12 +63,15 @@ public function validate(Pass $pass) return count($this->errors) === 0; } + /** + * {@inheritdoc} + */ public function getErrors() { return $this->errors; } - private function validateRequiredFields(Pass $pass) + private function validateRequiredFields(PassInterface $pass) { if ($this->isBlankOrNull($pass->getDescription())) { $this->addError(self::DESCRIPTION_REQUIRED); @@ -92,7 +98,7 @@ private function validateRequiredFields(Pass $pass) } } - private function validateBeaconKeys(Pass $pass) + private function validateBeaconKeys(PassInterface $pass) { $beacons = $pass->getBeacons(); @@ -120,7 +126,7 @@ private function validateBeacon(Beacon $beacon) } } - private function validateLocationKeys(Pass $pass) + private function validateLocationKeys(PassInterface $pass) { $locations = $pass->getLocations(); @@ -152,7 +158,7 @@ private function validateLocation(Location $location) } } - private function validateBarcodeKeys(Pass $pass) + private function validateBarcodeKeys(PassInterface $pass) { $validBarcodeFormats = array(Barcode::TYPE_QR, Barcode::TYPE_AZTEC, Barcode::TYPE_PDF_417, Barcode::TYPE_CODE_128); @@ -171,7 +177,7 @@ private function validateBarcodeKeys(Pass $pass) } } - private function validateWebServiceKeys(Pass $pass) + private function validateWebServiceKeys(PassInterface $pass) { if (null === $pass->getWebServiceURL()) { return; @@ -211,7 +217,7 @@ private function validateImageType(PassInterface $pass) } } - private function validateAssociatedStoreIdentifiers(Pass $pass) + private function validateAssociatedStoreIdentifiers(PassInterface $pass) { //appLaunchURL diff --git a/src/Passbook/PassValidatorInterface.php b/src/Passbook/PassValidatorInterface.php new file mode 100644 index 0000000..24e3789 --- /dev/null +++ b/src/Passbook/PassValidatorInterface.php @@ -0,0 +1,24 @@ +getErrors())); + self::assertEmpty($exception->getErrors()); + } + + public function testNewExceptionWithErrorsArray() + { + $errors = array('error 1', 'error 2'); + $exception = new PassInvalidException($errors); + + self::assertTrue(is_array($exception->getErrors())); + self::assertEquals($errors, $exception->getErrors()); + } + +} diff --git a/tests/Passbook/Tests/PassFactoryTest.php b/tests/Passbook/Tests/PassFactoryTest.php index 05aa9c9..9997402 100644 --- a/tests/Passbook/Tests/PassFactoryTest.php +++ b/tests/Passbook/Tests/PassFactoryTest.php @@ -5,6 +5,7 @@ use Passbook\Pass; use Passbook\Pass\Localization; use Passbook\PassFactory; +use Passbook\PassValidator; use Passbook\Type\EventTicket; use Passbook\Pass\Field; use Passbook\Pass\Barcode; @@ -131,6 +132,10 @@ public function testRequiredInformationInPassNotOverwrittenByFactory() $pass->setOrganizationName($passOrganizationName); $pass->setTeamIdentifier($passTeamIdentifier); $pass->setPassTypeIdentifier($passPassTypeIdentifier); + + // Icon is required + $icon = new Image(__DIR__.'/../../img/icon.png', 'icon'); + $pass->addImage($icon); $this->factory->setOutputPath('/tmp'); $this->factory->setOverwrite(true); @@ -141,6 +146,31 @@ public function testRequiredInformationInPassNotOverwrittenByFactory() self::assertEquals($passTeamIdentifier, $pass->getTeamIdentifier()); self::assertEquals($passPassTypeIdentifier, $pass->getPassTypeIdentifier()); } + + public function testNormalizedOutputPath() + { + $s = DIRECTORY_SEPARATOR; + + $this->factory->setOutputPath("path-ending-with-separator{$s}"); + self::assertEquals("path-ending-with-separator{$s}", $this->factory->getNormalizedOutputPath()); + + $this->factory->setOutputPath("path-not-ending-with-separator"); + self::assertEquals("path-not-ending-with-separator{$s}", $this->factory->getNormalizedOutputPath()); + + $this->factory->setOutputPath("path-ending-with-multiple-separators{$s}{$s}"); + self::assertEquals("path-ending-with-multiple-separators{$s}", $this->factory->getNormalizedOutputPath()); + } + + /** + * @expectedException \Passbook\Exception\PassInvalidException + */ + public function testPassThatFailsValidationThrowsException() + { + $this->factory->setPassValidator(new PassValidator()); + + $invalidPass = new Pass('serial number', 'description'); + $this->factory->package($invalidPass); + } public function testSpecifyPassName() { @@ -151,6 +181,10 @@ public function testSpecifyPassName() $pass = new Pass('serial number', 'description'); + // Icon is required + $icon = new Image(__DIR__.'/../../img/icon.png', 'icon'); + $pass->addImage($icon); + $this->factory->setOutputPath('/tmp'); $this->factory->setSkipSignature(true); $this->factory->package($pass, 'pass name');