From 4d90026936e63bf9e15e89ff6b8de001fed26ca1 Mon Sep 17 00:00:00 2001 From: Jared Whiklo Date: Wed, 17 Jan 2024 10:18:32 -0600 Subject: [PATCH 01/21] Initial BagItProfile work --- src/CurlInstance.php | 110 +++ src/Exceptions/ProfileException.php | 12 + src/Fetch.php | 105 +-- src/Profiles/BagItProfile.php | 836 ++++++++++++++++++++ src/Profiles/ProfileFactory.php | 44 ++ src/Profiles/ProfileTags.php | 99 +++ tests/Profiles/BasicProfileTest.php | 244 ++++++ tests/resources/profiles/bagProfileBar.json | 85 ++ tests/resources/profiles/bagProfileFoo.json | 38 + 9 files changed, 1470 insertions(+), 103 deletions(-) create mode 100644 src/CurlInstance.php create mode 100644 src/Exceptions/ProfileException.php create mode 100644 src/Profiles/BagItProfile.php create mode 100644 src/Profiles/ProfileFactory.php create mode 100644 src/Profiles/ProfileTags.php create mode 100644 tests/Profiles/BasicProfileTest.php create mode 100644 tests/resources/profiles/bagProfileBar.json create mode 100644 tests/resources/profiles/bagProfileFoo.json diff --git a/src/CurlInstance.php b/src/CurlInstance.php new file mode 100644 index 0000000..3f06ef5 --- /dev/null +++ b/src/CurlInstance.php @@ -0,0 +1,110 @@ + curl_handle + // $b -> expected download size (bytes) + // $c -> current download size (bytes) + // $d -> expected upload size (bytes) + // $e -> current upload size (bytes) + return self::curlXferInfo($size, $c); + }; + } else { + $options[CURLOPT_NOPROGRESS] = 1; + } + curl_setopt_array($ch, $options); + return $ch; + } + + /** + * Compares current download size versus expected for cUrl progress. + * @param int $expectDl + * The expected download size (bytes). + * @param int $currDl + * The current download size (bytes). + * @return int + * 1 if current download size is greater than 105% of the expected size. + */ + private static function curlXferInfo(int $expectDl, int $currDl): int + { + // Allow a 5% variance in size. + $variance = $expectDl * 1.05; + return ($currDl > $variance ? 1 : 0); + } + + /** + * Create a cUrl multi handler. + * + * @return CurlMultiHandle + * False on error, otherwise the cUrl resource + */ + public static function createMultiCurl(): CurlMultiHandle + { + $curlVersion = curl_version()['version']; + $mh = curl_multi_init(); + if ( + version_compare('7.62.0', $curlVersion) > 0 && + version_compare('7.43.0', $curlVersion) <= 0 + ) { + curl_multi_setopt($mh, CURLMOPT_PIPELINING, CURLPIPE_MULTIPLEX); + } + if (version_compare('7.30.0', $curlVersion) <= 0) { + // Set a limit to how many connections can be opened. + curl_multi_setopt($mh, CURLMOPT_MAX_TOTAL_CONNECTIONS, 10); + } + return $mh; + } + + /** + * Set general CURLOPTS based on the Curl version. + * @return array The options to set. + */ + private static function setupCurl(string $curlVersion): array + { + if (!defined('CURLMOPT_MAX_TOTAL_CONNECTIONS')) { + define('CURLMOPT_MAX_TOTAL_CONNECTIONS', 13); + } + if (!defined('CURL_PIPEWAIT')) { + define('CURL_PIPEWAIT', 237); + } + $curlOptions = []; + $curlOptions[CURLOPT_RETURNTRANSFER] = true; + if ( + version_compare('7.0', PHP_VERSION) <= 0 && + version_compare('7.43.0', $curlVersion) <= 0 + ) { + $curlOptions[CURL_PIPEWAIT] = true; + } + return $curlOptions; + } +} diff --git a/src/Exceptions/ProfileException.php b/src/Exceptions/ProfileException.php new file mode 100644 index 0000000..928b391 --- /dev/null +++ b/src/Exceptions/ProfileException.php @@ -0,0 +1,12 @@ +bag = $bag; $this->files = []; - $this->curlVersion = curl_version()['version']; $this->filename = $this->bag->makeAbsolute(self::FILENAME); - $this->setupCurl(); if ($load) { $this->loadFiles(); } @@ -400,86 +398,6 @@ private function saveFileData(string $content, string $destination): void } } - /** - * Create a cUrl multi handler. - * - * @return CurlMultiHandle - * The cUrl resource - */ - private function createMultiCurl(): CurlMultiHandle - { - $mh = curl_multi_init(); - if ( - version_compare('7.62.0', $this->curlVersion) > 0 && - version_compare('7.43.0', $this->curlVersion) <= 0 - ) { - // Try enabling HTTP/2 multiplexing if our version is less than 7.62 - curl_multi_setopt($mh, CURLMOPT_PIPELINING, CURLPIPE_MULTIPLEX); - } - if (version_compare('7.30.0', $this->curlVersion) <= 0) { - // Set a limit to how many connections can be opened. - curl_multi_setopt($mh, CURLMOPT_MAX_TOTAL_CONNECTIONS, 10); - } - return $mh; - } - - /** - * Initiate a cUrl handler - * - * @param string $url - * The URL to download. - * @param bool $single - * If this is a download() call versus a downloadAll() call. - * @param int|null $size - * Expected download size or null if unknown - * @return CurlHandle|false - * False on error, otherwise the cUl resource. - */ - private function createCurl(string $url, bool $single = false, ?int $size = null): CurlHandle|bool - { - $ch = curl_init($url); - if ($ch === false) { - return false; - } - $options = $this->curlOptions; - if ($single === true) { - // If this is set during curl_multi_exec, it swallows error messages. - $options[CURLOPT_FAILONERROR] = true; - } - if (is_int($size)) { - $options[CURLOPT_NOPROGRESS] = 0; - $options[CURLOPT_PROGRESSFUNCTION] = function ($a, $b, $c, $d, $e) use ($size) { - // PROGRESSFUNCTION variables are - // $a -> curl_handle - // $b -> expected download size (bytes) - // $c -> current download size (bytes) - // $d -> expected upload size (bytes) - // $e -> current upload size (bytes) - return self::curlXferInfo($size, $c); - }; - } else { - $options[CURLOPT_NOPROGRESS] = 1; - } - curl_setopt_array($ch, $options); - return $ch; - } - - /** - * Compares current download size versus expected for cUrl progress. - * @param int $expectDl - * The expected download size (bytes). - * @param int $currDl - * The current download size (bytes). - * @return int - * 1 if current download size is greater than 105% of the expected size. - */ - private static function curlXferInfo(int $expectDl, int $currDl): int - { - // Allow a 5% variance in size. - $variance = $expectDl * 1.05; - return ($currDl > $variance ? 1 : 0); - } - /** * Download files using Curl. * @@ -632,25 +550,6 @@ private function existsInFile(string $arg, string $key): bool return (in_array(strtolower($arg), $values)); } - /** - * Set general CURLOPTS based on the Curl version. - */ - private function setupCurl(): void - { - if (!defined('CURLMOPT_MAX_TOTAL_CONNECTIONS')) { - define('CURLMOPT_MAX_TOTAL_CONNECTIONS', 13); - } - if (!defined('CURL_PIPEWAIT')) { - define('CURL_PIPEWAIT', 237); - } - if ( - version_compare('7.0', PHP_VERSION) <= 0 && - version_compare('7.43.0', $this->curlVersion) <= 0 - ) { - $this->curlOptions[CURL_PIPEWAIT] = true; - } - } - /** * Reset the error and warning logs. */ diff --git a/src/Profiles/BagItProfile.php b/src/Profiles/BagItProfile.php new file mode 100644 index 0000000..f26e129 --- /dev/null +++ b/src/Profiles/BagItProfile.php @@ -0,0 +1,836 @@ +bagItProfileIdentifier; + } + + /** + * @param string $bagItProfileIdentifier The identifier (and resolvable URI) of the BagItProfile. + * @return BagItProfile The profile object. + */ + private function setProfileIdentifier(string $bagItProfileIdentifier): BagItProfile + { + $this->bagItProfileIdentifier = $bagItProfileIdentifier; + return $this; + } + + /** + * @return string The value of the BagItProfile specification that this profile conforms to. + */ + public function getProfileSpecVersion(): string + { + return $this->bagItProfileVersion; + } + + /** + * @param string $bagItProfileVersion The value of the BagItProfile specification that this profile conforms to. + * @return BagItProfile The profile object. + */ + private function setProfileSpecVersion(string $bagItProfileVersion): BagItProfile + { + $this->bagItProfileVersion = $bagItProfileVersion; + return $this; + } + + /** + * @return string The version of this BagItProfile specification. + */ + public function getVersion(): string + { + return $this->version; + } + + /** + * @param string $version The version of this BagItProfile specification. + * @return BagItProfile The profile object. + */ + private function setVersion(string $version): BagItProfile + { + $this->version = $version; + return $this; + } + + /** + * @return string The Organization responsible for this profile. + */ + public function getSourceOrganization(): string + { + return $this->sourceOrganization; + } + + /** + * @param string $sourceOrganization The Organization responsible for this profile. + * @return BagItProfile The profile object. + */ + private function setSourceOrganization(string $sourceOrganization): BagItProfile + { + $this->sourceOrganization = $sourceOrganization; + return $this; + } + + /** + * @return string A brief explanation of the purpose of this profile. + */ + public function getExternalDescription(): string + { + return $this->externalDescription; + } + + /** + * @param string $externalDescription A brief explanation of the purpose of this profile. + * @return BagItProfile The profile object. + */ + private function setExternalDescription(string $externalDescription): BagItProfile + { + $this->externalDescription = $externalDescription; + return $this; + } + + /** + * @return string|null The contact name for this profile or null if none. + */ + public function getContactName(): ?string + { + return $this->contactName; + } + + /** + * @param string|null $contactName The contact name for this profile or null if none. + * @return BagItProfile The profile object. + */ + private function setContactName(?string $contactName): BagItProfile + { + $this->contactName = $contactName; + return $this; + } + + /** + * @return string|null The contact phone for this profile or null if none. + */ + public function getContactPhone(): ?string + { + return $this->contactPhone; + } + + /** + * @param string|null $contactPhone The contact phone for this profile or null if none. + * @return BagItProfile The profile object. + */ + private function setContactPhone(?string $contactPhone): BagItProfile + { + $this->contactPhone = $contactPhone; + return $this; + } + + /** + * @return string|null The contact email for this profile or null if none. + */ + public function getContactEmail(): ?string + { + return $this->contactEmail; + } + + /** + * @param string|null $contactEmail The contact email for this profile if none. + * @return BagItProfile The profile object. + */ + private function setContactEmail(?string $contactEmail): BagItProfile + { + $this->contactEmail = $contactEmail; + return $this; + } + + /** + * @return array The list of acceptable tags for this profile. + */ + public function getBagInfoTags(): array + { + return $this->profileBagInfoTags; + } + + /** + * @param array $bagInfoTags Parsed profile Bag-Info sections + * @return BagItProfile The profile object. + * @throws ProfileException If invalid options are specified for a tag. + */ + private function setBagInfoTags(array $bagInfoTags): BagItProfile + { + $expectedKeys = ['required' => 0, 'values' => 0, 'repeatable' => 0, 'description' => 0]; + $this->profileBagInfoTags = []; + foreach ($bagInfoTags as $tagName => $tagOpts) { + if (count(array_diff_key($tagOpts, $expectedKeys)) > 0) { + throw new ProfileException("Invalid tag options for $tagName"); + } + if (array_key_exists($tagName, $this->profileBagInfoTags)) { + throw new ProfileException("Duplicate tag $tagName"); + } + if (self::matchStrings('BagIt-Profile-Identifier', $tagName)) { + $this->profileWarnings[] = "The tag BagIt-Profile-Identifier is always required, but SHOULD NOT be + listed under Bag-Info in the Profile."; + } else { + $this->profileBagInfoTags[$tagName] = ProfileTags::fromJson($tagName, $tagOpts); + } + } + return $this; + } + + /** + * @return array The list of required manifest algorithms. e.g. ["sha1", "md5"]. + */ + public function getManifestsRequired(): array + { + return $this->manifestsRequired; + } + + /** + * @param array $manifestsRequired The list of required manifest algorithms. e.g. ["sha1", "md5"]. + * @return BagItProfile The profile object. + */ + private function setManifestsRequired(array $manifestsRequired): BagItProfile + { + $this->manifestsRequired = $manifestsRequired; + return $this; + } + + /** + * @return array The list of allowed manifest algorithms. e.g. ["sha1", "md5"]. + */ + public function getManifestsAllowed(): array + { + return $this->manifestsAllowed; + } + + /** + * @param array $manifestsAllowed The list of allowed manifest algorithms. e.g. ["sha1", "md5"]. + * @return BagItProfile The profile object. + * @throws ProfileException If manifestsAllowed does not include all entries from manifestsRequired. + */ + private function setManifestsAllowed(array $manifestsAllowed): BagItProfile + { + if ($this->manifestsRequired !== [] && array_diff($this->manifestsRequired, $manifestsAllowed) !== []) { + throw new ProfileException("Manifests-Allowed must include all entries from Manifests-Required"); + } + $this->manifestsAllowed = $manifestsAllowed; + return $this; + } + + /** + * @return bool Whether to allow the existence of a fetch.txt file. Default is true. + */ + public function isAllowFetchTxt(): bool + { + return $this->allowFetchTxt; + } + + /** + * @param bool $allowFetchTxt Whether to allow the existence of a fetch.txt file. Default is true. + * @return BagItProfile The profile object. + * @throws ProfileException If requireFetchTxt is true and allowFetchTxt is false. + */ + private function setAllowFetchTxt(?bool $allowFetchTxt): BagItProfile + { + if ($this->requireFetchTxt === true && $allowFetchTxt === false) { + throw new ProfileException("Allow-Fetch.txt cannot be false if Require-Fetch.txt is true"); + } + $this->allowFetchTxt = $allowFetchTxt ?? true; + return $this; + } + + /** + * @return bool Whether to require the existence of a fetch.txt file. Default is false. + */ + public function isRequireFetchTxt(): bool + { + return $this->requireFetchTxt; + } + + /** + * @param bool $requireFetchTxt Whether to require the existence of a fetch.txt file. Default is false. + * @return BagItProfile The profile object. + * @throws ProfileException If requireFetchTxt is true and allowFetchTxt is false. + */ + private function setRequireFetchTxt(?bool $requireFetchTxt): BagItProfile + { + if ($requireFetchTxt === true && $this->allowFetchTxt === false) { + throw new ProfileException("Require-Fetch.txt cannot be true if Allow-Fetch.txt is false"); + } + $this->requireFetchTxt = $requireFetchTxt ?? false; + return $this; + } + + /** + * @return bool If true then the /data directory must contain either no files or a single zero byte + */ + public function isDataEmpty(): bool + { + return $this->dataEmpty; + } + + /** + * @param bool $dataEmpty If true then the /data directory must contain either no files or a single zero byte + * @return BagItProfile The profile object. + */ + private function setDataEmpty(bool $dataEmpty): BagItProfile + { + $this->dataEmpty = $dataEmpty; + return $this; + } + + /** + * @return string The serialization option value. One of forbidden, required, optional. + */ + public function getSerialization(): string + { + return $this->serialization; + } + + /** + * @param string $serialization The serialization option value. + * @return BagItProfile The profile object. + */ + private function setSerialization(string $serialization): BagItProfile + { + $this->serialization = $serialization; + return $this; + } + + /** + * @return array The list of MIME types acceptable as serialized formats. + */ + public function getAcceptSerialization(): array + { + return $this->acceptSerialization; + } + + /** + * @param array|null $acceptSerialization The list of MIME types acceptable as serialized formats. + * @return BagItProfile The profile object. + */ + private function setAcceptSerialization(?array $acceptSerialization): BagItProfile + { + $this->acceptSerialization = $acceptSerialization; + return $this; + } + + /** + * @return array The list of BagIt version numbers that will be accepted, e.g. "1.0". + */ + public function getAcceptBagItVersion(): array + { + return $this->acceptBagItVersion; + } + + /** + * @param array $acceptBagItVersion The list of BagIt version numbers that will be accepted, e.g. "1.0". + * @return BagItProfile The profile object. + */ + private function setAcceptBagItVersion(array $acceptBagItVersion): BagItProfile + { + $this->acceptBagItVersion = $acceptBagItVersion; + return $this; + } + + /** + * @return array The list of required tag manifest algorithms. e.g. ["sha1", "md5"]. + */ + public function getTagManifestsRequired(): array + { + return $this->tagManifestsRequired; + } + + /** + * @param array $tagManifestsRequired The list of required tag manifest algorithms. e.g. ["sha1", "md5"]. + * @return BagItProfile The profile object. + */ + private function setTagManifestsRequired(array $tagManifestsRequired): BagItProfile + { + $this->tagManifestsRequired = $tagManifestsRequired; + return $this; + } + + /** + * @return array The list of allowed tag manifest algorithms. e.g. ["sha1", "md5"]. + */ + public function getTagManifestsAllowed(): array + { + return $this->tagManifestsAllowed; + } + + /** + * @param array $tagManifestAllowed The list of allowed tag manifest algorithms. e.g. ["sha1", "md5"]. + * @return BagItProfile The profile object. + */ + private function setTagManifestsAllowed(array $tagManifestAllowed): BagItProfile + { + $this->tagManifestsAllowed = $tagManifestAllowed; + return $this; + } + + /** + * @return array The list of tag files that MUST be included in a conformant Bag. + */ + public function getTagFilesRequired(): array + { + return $this->tagFilesRequired; + } + + /** + * @param array $tagFilesRequired The list of tag files that MUST be included in a conformant Bag. + * @return BagItProfile The profile object. + */ + private function setTagFilesRequired(array $tagFilesRequired): BagItProfile + { + $this->tagFilesRequired = $tagFilesRequired; + return $this; + } + + /** + * @return array The list of tag files that MAY be included in a conformant Bag. + */ + public function getTagFilesAllowed(): array + { + return $this->tagFilesAllowed; + } + + /** + * @param array $tagFilesAllowed The list of tag files/paths that MAY be included in a conformant Bag. + * @return BagItProfile The profile object. + */ + private function setTagFilesAllowed(array $tagFilesAllowed): BagItProfile + { + $this->tagFilesAllowed = $tagFilesAllowed; + return $this; + } + + /** + * Assert that the array of required paths are covered by the array of allowed paths. + * @param array $required The list of required paths. + * @param array $allowed The list of allowed paths. + * @return bool True if all required paths are covered by allowed paths. + */ + private function isRequiredPathsCoveredByAllowed(array $required, array $allowed): bool + { + if (count($required) === 0 || count($allowed) === 0) { + return true; + } + $perfect_match = array_intersect($required, $allowed); + if (count($perfect_match) === count($required)) { + return true; + } + $remaining = array_diff($required, $perfect_match); + foreach ($allowed as $allowedFile) { + $regex = $this->convertGlobToRegex($allowedFile); + $matching = array_filter($remaining, function ($tagFile) use ($regex) { + return preg_match($regex, $tagFile) === 1; + }); + if (count($matching) > 0) { + $remaining = array_diff($remaining, $matching); + } + if (count($remaining) === 0) { + return true; + } + } + return false; + } + + /** + * Convert a glob pattern to a regex pattern. + * @param string $glob The glob pattern. + * @return string The regex pattern. + */ + private function convertGlobToRegex(string $glob): string + { + $regex = str_replace('/', '\/', $glob); + $regex = str_replace('.', '\.', $regex); + return preg_replace('~[^\]*', '.*', $regex); + } + + /** + * @return array The list of payload files that MUST be included in a conformant Bag. + */ + public function getPayloadFilesRequired(): array + { + return $this->payloadFilesRequired; + } + + /** + * @param array $payloadFilesRequired The list of payload files that MUST be included in a conformant Bag. + * @return BagItProfile The profile object. + */ + private function setPayloadFilesRequired(array $payloadFilesRequired): BagItProfile + { + $this->payloadFilesRequired = $payloadFilesRequired; + return $this; + } + + /** + * @return array The list of payload files that MAY be included in a conformant Bag. + */ + public function getPayloadFilesAllowed(): array + { + return $this->payloadFilesAllowed; + } + + /** + * @param array $payloadFilesAllowed The list of payload files/paths that MAY be included in a conformant Bag. + * @return BagItProfile The profile object. + */ + private function setPayloadFilesAllowed(array $payloadFilesAllowed): BagItProfile + { + $this->payloadFilesAllowed = $payloadFilesAllowed; + return $this; + } + + /** + * Case-insensitive string comparison. + * @param string $expected The expected string. + * @param string|null $provided The provided string. + * @return bool True if the strings match. + */ + private static function matchStrings(string $expected, ?string $provided): bool + { + return ($provided !== null && strtolower(trim($expected)) === strtolower(trim($provided))); + } + + /** + * Create a BagItProfile from a JSON string. + * @param string|null $json_string The BagItProfile JSON string. + * @throws ProfileException If there are errors with the profile. + */ + public static function fromJson(?string $json_string): BagItProfile + { + $profileOptions = json_decode($json_string, true); + if ($profileOptions === null) { + throw new ProfileException("Error parsing profile"); + } + $profile = new BagItProfile(); + try { + $profile->setProfileIdentifier($profileOptions['BagIt-Profile-Info']['BagIt-Profile-Identifier']) + ->setSourceOrganization($profileOptions['BagIt-Profile-Info']['Source-Organization']) + ->setExternalDescription($profileOptions['BagIt-Profile-Info']['External-Description']) + ->setVersion($profileOptions['BagIt-Profile-Info']['Version']); + } catch (Exception $e) { + throw new ProfileException("Missing required BagIt-Profile-Info tag", $e); + } + if (array_key_exists('BagIt-Profile-Version', $profileOptions['BagIt-Profile-Info'])) { + $profile->setProfileSpecVersion($profileOptions['BagIt-Profile-Info']['BagIt-Profile-Version']); + } + if (array_key_exists('Contact-Name', $profileOptions['BagIt-Profile-Info'])) { + $profile->setContactName($profileOptions['BagIt-Profile-Info']['Contact-Name']); + } + if (array_key_exists('Contact-Phone', $profileOptions['BagIt-Profile-Info'])) { + $profile->setContactPhone($profileOptions['BagIt-Profile-Info']['Contact-Phone']); + } + if (array_key_exists('Contact-Email', $profileOptions['BagIt-Profile-Info'])) { + $profile->setContactEmail($profileOptions['BagIt-Profile-Info']['Contact-Email']); + } + if (array_key_exists('Bag-Info', $profileOptions)) { + $profile->setBagInfoTags($profileOptions['Bag-Info']); + } + if (array_key_exists('Manifests-Required', $profileOptions)) { + $profile->setManifestsRequired($profileOptions['Manifests-Required']); + } + if (array_key_exists('Manifests-Allowed', $profileOptions)) { + $profile->setManifestsAllowed($profileOptions['Manifests-Allowed']); + } + if (array_key_exists('Allow-Fetch.txt', $profileOptions)) { + $profile->setAllowFetchTxt($profileOptions['Allow-Fetch.txt']); + } + if (array_key_exists('Require-Fetch.txt', $profileOptions)) { + $profile->setRequireFetchTxt($profileOptions['Require-Fetch.txt']); + } + if (array_key_exists('Data-Empty', $profileOptions)) { + $profile->setDataEmpty($profileOptions['Data-Empty']); + } + if (array_key_exists('Serialization', $profileOptions)) { + $profile->setSerialization($profileOptions['Serialization']); + } + if (array_key_exists('Accept-Serialization', $profileOptions)) { + $profile->setAcceptSerialization($profileOptions['Accept-Serialization']); + } + if (array_key_exists('Accept-BagIt-Version', $profileOptions)) { + $profile->setAcceptBagItVersion($profileOptions['Accept-BagIt-Version']); + } + if (array_key_exists('Tag-Manifests-Required', $profileOptions)) { + $profile->setTagManifestsRequired($profileOptions['Tag-Manifests-Required']); + } + if (array_key_exists('Tag-Manifests-Allowed', $profileOptions)) { + $profile->setTagManifestsAllowed($profileOptions['Tag-Manifests-Allowed']); + } + if (array_key_exists('Tag-Files-Required', $profileOptions)) { + $profile->setTagFilesRequired($profileOptions['Tag-Files-Required']); + } + if (array_key_exists('Tag-Files-Allowed', $profileOptions)) { + $profile->setTagFilesAllowed($profileOptions['Tag-Files-Allowed']); + } + if (array_key_exists('Payload-Files-Required', $profileOptions)) { + $profile->setPayloadFilesRequired($profileOptions['Payload-Files-Required']); + } + if (array_key_exists('Payload-Files-Allowed', $profileOptions)) { + $profile->setPayloadFilesAllowed($profileOptions['Payload-Files-Allowed']); + } + return $profile; + } + + /** + * Validate this profile. + * @return bool True if the profile is valid. + * @throws ProfileException If the profile is not valid. + */ + public function validate(): bool + { + $errors = []; + if ($this->getProfileIdentifier() === "") { + $errors[] = "BagIt-Profile-Identifier is required"; + } + if (count($this->getAcceptBagItVersion()) === 0) { + $errors[] = "Accept-BagIt-Version must contain at least one version"; + } + if (!in_array($this->getSerialization(), ['forbidden', 'required', 'optional'])) { + $errors[] = "Serialization must be one of forbidden, required, optional"; + } + if ( + in_array($this->getSerialization(), ['required', 'optional']) && + ($this->getAcceptSerialization() === null || count($this->getAcceptSerialization()) === 0) + ) { + $errors[] = "Accept-Serialization MIME type(s) must be specified if Serialization + is required or optional"; + } + if ( + !$this->isRequiredPathsCoveredByAllowed( + $this->getManifestsRequired(), + $this->getManifestsAllowed() + ) + ) { + $errors[] = "Manifests-Allowed must include all entries from Manifests-Required"; + } + if ( + !$this->isRequiredPathsCoveredByAllowed( + $this->getTagManifestsRequired(), + $this->getTagManifestsAllowed() + ) + ) { + $errors[] = "Tag-Manifests-Allowed must include all entries from Tag-Manifests-Required"; + } + if ( + !$this->isRequiredPathsCoveredByAllowed( + $this->getTagFilesRequired(), + $this->getTagFilesAllowed() + ) + ) { + $errors[] = "Tag-Files-Allowed must include all entries from Tag-Files-Required"; + } + if ( + !$this->isRequiredPathsCoveredByAllowed( + $this->getPayloadFilesRequired(), + $this->getPayloadFilesAllowed() + ) + ) { + $errors[] = "Payload-Files-Allowed must include all entries from Payload-Files-Required"; + } + if ( + !$this->isRequiredPathsCoveredByAllowed( + $this->getPayloadFilesRequired(), + $this->getPayloadFilesAllowed() + ) + ) { + $errors[] = "Payload-Files-Allowed must include all entries from Payload-Files-Required"; + } + if (count($errors) > 0) { + throw new ProfileException(implode("\n", $errors)); + } + return true; + } +} diff --git a/src/Profiles/ProfileFactory.php b/src/Profiles/ProfileFactory.php new file mode 100644 index 0000000..053d3da --- /dev/null +++ b/src/Profiles/ProfileFactory.php @@ -0,0 +1,44 @@ +tag = $tag; + $this->required = $required; + $this->values = $values; + $this->repeatable = $repeatable; + $this->description = $description; + } + + /** + * @return string + */ + public function getTag(): string + { + return $this->tag; + } + + /** + * @return bool + */ + public function isRequired(): bool + { + return $this->required; + } + + /** + * @return array + */ + public function getValues(): array + { + return $this->values; + } + + /** + * @return bool + */ + public function isRepeatable(): bool + { + return $this->repeatable; + } + + /** + * @return string + */ + public function getDescription(): string + { + return $this->description; + } + + public static function fromJson(string $tag, array $tagOpts): ProfileTags + { + return new ProfileTags( + $tag, + $tagOpts['required'] ?? false, + $tagOpts['values'] ?? [], + $tagOpts['repeatable'] ?? true, + $tagOpts['description'] ?? "" + ); + } +} diff --git a/tests/Profiles/BasicProfileTest.php b/tests/Profiles/BasicProfileTest.php new file mode 100644 index 0000000..b7f1e89 --- /dev/null +++ b/tests/Profiles/BasicProfileTest.php @@ -0,0 +1,244 @@ +assertEquals( + 'http://www.library.yale.edu/mssa/bagitprofiles/disk_images.json', + $profile->getProfileIdentifier() + ); + $this->assertEquals('1.1.0', $profile->getProfileSpecVersion()); + $this->assertEquals('0.3', $profile->getVersion()); + $this->assertEquals('Yale University', $profile->getSourceOrganization()); + $this->assertEquals('BagIt Profile for packaging disk images', $profile->getExternalDescription()); + $this->assertEquals('Mark Matienzo', $profile->getContactName()); + $this->assertNull($profile->getContactEmail()); + $this->assertNull($profile->getContactPhone()); + $expected_tags = [ + 'Bagging-Date' => [ + 'required' => true, + 'values' => [], + 'repeatable' => true, + 'description' => '', + ], + 'Source-Organization' => [ + 'required' => true, + 'values' => [ + 'Simon Fraser University', + 'York University', + ], + 'repeatable' => true, + 'description' => '', + ], + ]; + $this->assertProfileBagInfoTags($expected_tags, $profile); + $this->assertArrayEquals(['md5'], $profile->getManifestsRequired()); + $this->assertArrayEquals([], $profile->getManifestsAllowed()); + $this->assertArrayEquals([], $profile->getTagManifestsRequired()); + $this->assertArrayEquals([], $profile->getTagManifestsAllowed()); + $this->assertArrayEquals([], $profile->getTagFilesRequired()); + $this->assertArrayEquals([], $profile->getTagFilesAllowed()); + $this->assertFalse($profile->isAllowFetchTxt()); + $this->assertFalse($profile->isRequireFetchTxt()); + $this->assertFalse($profile->isDataEmpty()); + $this->assertEquals("required", $profile->getSerialization()); + $this->assertArrayEquals( + [ + "application/zip", + "application/tar" + ], + $profile->getAcceptSerialization() + ); + $this->assertArrayEquals( + [ + "0.96", + "0.97", + ], + $profile->getAcceptBagItVersion() + ); + $this->assertArrayEquals([], $profile->getPayloadFilesRequired()); + $this->assertArrayEquals([], $profile->getPayloadFilesAllowed()); + } + + /** + * Test the second example profile from the specification. + * @return void + * @throws ProfileException + */ + public function testSpecProfileBar(): void + { + $json = file_get_contents(self::TEST_RESOURCES . '/profiles/bagProfileBar.json'); + $profile = BagItProfile::fromJson($json); + $this->assertEquals( + 'http://canadiana.org/standards/bagit/tdr_ingest.json', + $profile->getProfileIdentifier() + ); + $this->assertEquals('1.2.0', $profile->getProfileSpecVersion()); + $this->assertEquals('1.2', $profile->getVersion()); + $this->assertEquals('Candiana.org', $profile->getSourceOrganization()); + $this->assertEquals( + 'BagIt Profile for ingesting content into the C.O. TDR loading dock.', + $profile->getExternalDescription() + ); + $this->assertEquals('William Wueppelmann', $profile->getContactName()); + $this->assertEquals('tdr@canadiana.com', $profile->getContactEmail()); + $this->assertNull($profile->getContactPhone()); + $expected_tags = [ + "Source-Organization" => [ + "required" => true, + "values" => [ + "Simon Fraser University", + "York University" + ], + 'repeatable' => true, + 'description' => '', + ], + "Organization-Address" => [ + "required" => true, + "values" => [ + "8888 University Drive Burnaby, B.C. V5A 1S6 Canada", + "4700 Keele Street Toronto, Ontario M3J 1P3 Canada" + ], + 'repeatable' => true, + 'description' => '', + ], + "Contact-Name" => [ + "required" => true, + "values" => [ + "Mark Jordan", + "Nick Ruest" + ], + 'repeatable' => true, + 'description' => '', + ], + "Contact-Phone" => [ + "required" => false, + 'values' => [], + 'repeatable' => true, + 'description' => '', + ], + "Contact-Email" => [ + "required" => true, + 'values' => [], + 'repeatable' => true, + 'description' => '', + ], + "External-Description" => [ + "required" => true, + 'values' => [], + 'repeatable' => true, + 'description' => '', + ], + "External-Identifier" => [ + "required" => false, + 'values' => [], + 'repeatable' => true, + 'description' => '', + ], + "Bag-Size" => [ + "required" => true, + 'values' => [], + 'repeatable' => true, + 'description' => '', + ], + "Bag-Group-Identifier" => [ + "required" => false, + 'values' => [], + 'repeatable' => true, + 'description' => '', + ], + "Bag-Count" => [ + "required" => true, + 'values' => [], + 'repeatable' => true, + 'description' => '', + ], + "Internal-Sender-Identifier" => [ + "required" => false, + 'values' => [], + 'repeatable' => true, + 'description' => '', + ], + "Internal-Sender-Description" => [ + "required" => false, + 'values' => [], + 'repeatable' => true, + 'description' => '', + ], + "Bagging-Date" => [ + "required" => true, + 'values' => [], + 'repeatable' => true, + 'description' => '', + ], + "Payload-Oxum" => [ + "required" => true, + 'values' => [], + 'repeatable' => true, + 'description' => '', + ], + ]; + $this->assertProfileBagInfoTags($expected_tags, $profile); + $this->assertArrayEquals(['md5'], $profile->getManifestsRequired()); + $this->assertArrayEquals([], $profile->getManifestsAllowed()); + $this->assertArrayEquals(['md5'], $profile->getTagManifestsRequired()); + $this->assertArrayEquals([], $profile->getTagManifestsAllowed()); + $this->assertArrayEquals( + [ + "DPN/dpnFirstNode.txt", + "DPN/dpnRegistry" + ], + $profile->getTagFilesRequired() + ); + $this->assertArrayEquals([], $profile->getTagFilesAllowed()); + $this->assertFalse($profile->isAllowFetchTxt()); + $this->assertFalse($profile->isRequireFetchTxt()); + $this->assertFalse($profile->isDataEmpty()); + $this->assertEquals("optional", $profile->getSerialization()); + $this->assertArrayEquals( + [ + "application/zip", + ], + $profile->getAcceptSerialization() + ); + $this->assertArrayEquals( + [ + "0.96", + ], + $profile->getAcceptBagItVersion() + ); + $this->assertArrayEquals([], $profile->getPayloadFilesRequired()); + $this->assertArrayEquals([], $profile->getPayloadFilesAllowed()); + } + + /** + * Assert the bag-info tags are as expected. + * @param array $expected The expected tags. + * @param BagItProfile $profile The profile to check. + */ + private function assertProfileBagInfoTags(array $expected, BagItProfile $profile): void + { + foreach ($expected as $tag => $value) { + $this->assertArrayHasKey($tag, $profile->getBagInfoTags()); + $profileTag = $profile->getBagInfoTags()[$tag]; + $this->assertEquals($value['required'], $profileTag->isRequired()); + $this->assertEquals($value['repeatable'], $profileTag->isRepeatable()); + $this->assertArrayEquals($value['values'], $profileTag->getValues()); + $this->assertEquals($value['description'], $profileTag->getDescription()); + } + } +} diff --git a/tests/resources/profiles/bagProfileBar.json b/tests/resources/profiles/bagProfileBar.json new file mode 100644 index 0000000..4ce40f1 --- /dev/null +++ b/tests/resources/profiles/bagProfileBar.json @@ -0,0 +1,85 @@ +{ + "BagIt-Profile-Info":{ + "BagIt-Profile-Identifier":"http://canadiana.org/standards/bagit/tdr_ingest.json", + "BagIt-Profile-Version": "1.2.0", + "Source-Organization":"Candiana.org", + "Contact-Name":"William Wueppelmann", + "Contact-Email":"tdr@canadiana.com", + "External-Description":"BagIt Profile for ingesting content into the C.O. TDR loading dock.", + "Version":"1.2" + }, + "Bag-Info":{ + "Source-Organization":{ + "required":true, + "values":[ + "Simon Fraser University", + "York University" + ] + }, + "Organization-Address":{ + "required":true, + "values":[ + "8888 University Drive Burnaby, B.C. V5A 1S6 Canada", + "4700 Keele Street Toronto, Ontario M3J 1P3 Canada" + ] + }, + "Contact-Name":{ + "required":true, + "values":[ + "Mark Jordan", + "Nick Ruest" + ] + }, + "Contact-Phone":{ + "required":false + }, + "Contact-Email":{ + "required":true + }, + "External-Description":{ + "required":true + }, + "External-Identifier":{ + "required":false + }, + "Bag-Size":{ + "required":true + }, + "Bag-Group-Identifier":{ + "required":false + }, + "Bag-Count":{ + "required":true + }, + "Internal-Sender-Identifier":{ + "required":false + }, + "Internal-Sender-Description":{ + "required":false + }, + "Bagging-Date":{ + "required":true + }, + "Payload-Oxum":{ + "required":true + } + }, + "Manifests-Required":[ + "md5" + ], + "Allow-Fetch.txt":false, + "Serialization":"optional", + "Accept-Serialization":[ + "application/zip" + ], + "Tag-Manifests-Required":[ + "md5" + ], + "Tag-Files-Required":[ + "DPN/dpnFirstNode.txt", + "DPN/dpnRegistry" + ], + "Accept-BagIt-Version":[ + "0.96" + ] +} diff --git a/tests/resources/profiles/bagProfileFoo.json b/tests/resources/profiles/bagProfileFoo.json new file mode 100644 index 0000000..fd3c40e --- /dev/null +++ b/tests/resources/profiles/bagProfileFoo.json @@ -0,0 +1,38 @@ +{ + "BagIt-Profile-Info":{ + "BagIt-Profile-Identifier":"http://www.library.yale.edu/mssa/bagitprofiles/disk_images.json", + "BagIt-Profile-Version": "1.1.0", + "Source-Organization":"Yale University", + "Contact-Name":"Mark Matienzo", + "External-Description":"BagIt Profile for packaging disk images", + "Version":"0.3" + }, + "Bag-Info":{ + "Bagging-Date":{ + "required":true + }, + "Source-Organization":{ + "required":true, + "values":[ + "Simon Fraser University", + "York University" + ] + }, + "Contact-Phone":{ + "required":true + } + }, + "Manifests-Required":[ + "md5" + ], + "Allow-Fetch.txt":false, + "Serialization":"required", + "Accept-Serialization":[ + "application/zip", + "application/tar" + ], + "Accept-BagIt-Version":[ + "0.96", + "0.97" + ] +} From 900c38a44d63e1f824be17d58b3264636df11439 Mon Sep 17 00:00:00 2001 From: Jared Whiklo Date: Wed, 17 Jan 2024 14:19:26 -0600 Subject: [PATCH 02/21] More testing --- src/CurlInstance.php | 6 +- src/Fetch.php | 17 ------ src/Profiles/BagItProfile.php | 43 ++++++--------- src/Profiles/ProfileFactory.php | 11 +++- src/Profiles/ProfileTags.php | 9 +++ tests/Profiles/BasicProfileTest.php | 7 ++- tests/Profiles/InternalProfileTest.php | 76 ++++++++++++++++++++++++++ 7 files changed, 120 insertions(+), 49 deletions(-) create mode 100644 tests/Profiles/InternalProfileTest.php diff --git a/src/CurlInstance.php b/src/CurlInstance.php index 3f06ef5..202fce4 100644 --- a/src/CurlInstance.php +++ b/src/CurlInstance.php @@ -97,8 +97,10 @@ private static function setupCurl(string $curlVersion): array if (!defined('CURL_PIPEWAIT')) { define('CURL_PIPEWAIT', 237); } - $curlOptions = []; - $curlOptions[CURLOPT_RETURNTRANSFER] = true; + $curlOptions = [ + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_RETURNTRANSFER => true, + ]; if ( version_compare('7.0', PHP_VERSION) <= 0 && version_compare('7.43.0', $curlVersion) <= 0 diff --git a/src/Fetch.php b/src/Fetch.php index cabc7c9..14fa659 100644 --- a/src/Fetch.php +++ b/src/Fetch.php @@ -59,23 +59,6 @@ class Fetch */ private array $downloadQueue = []; - /** - * Curl version number string. - * - * @var string - */ - private mixed $curlVersion; - - /** - * Standard curl options to use. - * - * @var array - */ - private array $curlOptions = [ - CURLOPT_CONNECTTIMEOUT => 10, - CURLOPT_RETURNTRANSFER => true, - ]; - /** * Fetch constructor. * diff --git a/src/Profiles/BagItProfile.php b/src/Profiles/BagItProfile.php index f26e129..b2aff43 100644 --- a/src/Profiles/BagItProfile.php +++ b/src/Profiles/BagItProfile.php @@ -1,34 +1,21 @@ setExternalDescription($profileOptions['BagIt-Profile-Info']['External-Description']) ->setVersion($profileOptions['BagIt-Profile-Info']['Version']); } catch (Exception $e) { - throw new ProfileException("Missing required BagIt-Profile-Info tag", $e); + throw new ProfileException("Missing required BagIt-Profile-Info tag", $e->getCode(), $e); } if (array_key_exists('BagIt-Profile-Version', $profileOptions['BagIt-Profile-Info'])) { $profile->setProfileSpecVersion($profileOptions['BagIt-Profile-Info']['BagIt-Profile-Version']); @@ -783,7 +772,7 @@ public function validate(): bool } if ( in_array($this->getSerialization(), ['required', 'optional']) && - ($this->getAcceptSerialization() === null || count($this->getAcceptSerialization()) === 0) + count($this->getAcceptSerialization()) === 0 ) { $errors[] = "Accept-Serialization MIME type(s) must be specified if Serialization is required or optional"; diff --git a/src/Profiles/ProfileFactory.php b/src/Profiles/ProfileFactory.php index 053d3da..67dc0ec 100644 --- a/src/Profiles/ProfileFactory.php +++ b/src/Profiles/ProfileFactory.php @@ -1,10 +1,19 @@ '~^[^/]+$~', + 'myFiles*' => '~^myFiles[^/]+$~', + 'data/some/*/file.txt' => '~^data/some/[^/]+/file\.txt$~', + 'some/directories/' => '~^some/directories/$~', + 'some/more/directories/*' => '~^some/more/directories/[^/]+$~', + 'some/directory/[a-z].txt' => '~^some/directory/[a-z]\.txt$~', + 'some/directory/[!a-z].txt' => '~^some/directory/[^a-z]\.txt$~', + ]; + + $method = $this->getReflectionMethod('\whikloj\BagItTools\Profiles\BagItProfile', 'convertGlobToRegex'); + + $profile = new BagItProfile(); + + foreach ($test_cases as $glob => $expected) { + $result = $method->invokeArgs($profile, [$glob]); + $this->assertEquals($expected, $result); + } + } + + /** + * Test conversion and matching of glob required to allowed paths. + * @throws ReflectionException + * @covers ::isRequiredPathsCoveredByAllowed + * @covers ::convertGlobToRegex + */ + public function testRequiredVersusAllowedTag(): void + { + $method = $this->getReflectionMethod( + '\whikloj\BagItTools\Profiles\BagItProfile', + 'isRequiredPathsCoveredByAllowed' + ); + + $profile = new BagItProfile(); + + $this->assertTrue( + $method->invokeArgs($profile, [['DPN/dpnFirstNode.txt', 'DPN/dpnRegistry'], ['DPN/*']]) + ); + + $this->assertFalse( + $method->invokeArgs($profile, [['DPN/dpnFirstNode.txt', 'DPN/dpnRegistry'], ['DPN/dpnFirstNode.txt']]) + ); + + $this->assertTrue( + $method->invokeArgs($profile, [ + ['DPN/dpnFirstNode.txt', 'DPN/dpnRegistry'], ['DPN/dpnFirstNode.txt', 'DPN/dpnRegistry'] + ]) + ); + + $this->assertFalse( + $method->invokeArgs($profile, [ + ['DPN/dpnFirstNode.txt', 'DPN/dpnRegistry'], ['DPN/'] + ]) + ); + } +} From 9c1dc91d705530d8603f1784a31c80ab3f9a1931 Mon Sep 17 00:00:00 2001 From: Jared Whiklo Date: Wed, 17 Jan 2024 15:04:51 -0600 Subject: [PATCH 03/21] More tests --- src/Profiles/BagItProfile.php | 2 +- tests/BagItWebserverFramework.php | 70 +++++++++++++++++ tests/FetchTest.php | 115 ++++++++-------------------- tests/Profiles/BasicProfileTest.php | 85 +++++++++++++++++--- 4 files changed, 181 insertions(+), 91 deletions(-) create mode 100644 tests/BagItWebserverFramework.php diff --git a/src/Profiles/BagItProfile.php b/src/Profiles/BagItProfile.php index b2aff43..4649412 100644 --- a/src/Profiles/BagItProfile.php +++ b/src/Profiles/BagItProfile.php @@ -758,7 +758,7 @@ public static function fromJson(?string $json_string): BagItProfile * @return bool True if the profile is valid. * @throws ProfileException If the profile is not valid. */ - public function validate(): bool + public function isValid(): bool { $errors = []; if ($this->getProfileIdentifier() === "") { diff --git a/tests/BagItWebserverFramework.php b/tests/BagItWebserverFramework.php new file mode 100644 index 0000000..56ad14f --- /dev/null +++ b/tests/BagItWebserverFramework.php @@ -0,0 +1,70 @@ +start(); + $counter = 0; + foreach (self::$webserver_files as $file) { + self::$response_content[$counter] = file_get_contents($file['filename']); + // Add custom headers if defined. + $response_headers = [ 'Cache-Control' => 'no-cache', 'Content-Length' => stat($file['filename'])['size']] + + ($file['headers'] ?? []); + // Use custom status code if defined. + $status_code = $file['status_code'] ?? 200; + self::$remote_urls[$counter] = self::$webserver->setResponseOfPath( + "/example/" . basename($file['filename']), + new Response( + self::$response_content[$counter], + $response_headers, + $status_code + ) + ); + $counter += 1; + } + } + + /** + * {@inheritdoc} + */ + public static function tearDownAfterClass(): void + { + self::$webserver->stop(); + } +} diff --git a/tests/FetchTest.php b/tests/FetchTest.php index 5e673e9..aeaafce 100644 --- a/tests/FetchTest.php +++ b/tests/FetchTest.php @@ -16,7 +16,7 @@ * @package whikloj\BagItTools\Test * @coversDefaultClass \whikloj\BagItTools\Fetch */ -class FetchTest extends BagItTestFramework +class FetchTest extends BagItWebserverFramework { /** * Location of fetch file test resources. @@ -29,89 +29,42 @@ class FetchTest extends BagItTestFramework private const WEBSERVER_FILES_DIR = self::TEST_RESOURCES . '/webserver_responses'; /** - * Array of remote files defined in mock webserver. + * Setup the webserver files. */ - private const WEBSERVER_FILES = [ - 'remote_file1.txt' => [ - 'filename' => self::WEBSERVER_FILES_DIR . '/remote_file1.txt', - 'checksums' => [ - 'sha512' => 'fd7c6f2a22f5dffac90c4483c9d623206a237a523b8e5a6f291ac0678fb6a3b5d68bb09a779c1809a15d8ef' . - '8c7d4e16a6d18d50c9b7f9639fd0d8fcf2b7ef46a', + public static function setUpBeforeClass(): void + { + self::$webserver_files = [ + 'remote_file1.txt' => [ + 'filename' => self::WEBSERVER_FILES_DIR . '/remote_file1.txt', + 'checksums' => [ + 'sha512' => 'fd7c6f2a22f5dffac90c4483c9d623206a237a523b8e5a6f291ac0678fb6a3b5d68bb09a779c1809a15' . + 'd8ef8c7d4e16a6d18d50c9b7f9639fd0d8fcf2b7ef46a', + ], ], - ], - 'remote_file2.txt' => [ - 'filename' => self::WEBSERVER_FILES_DIR . '/remote_file2.txt', - 'checksums' => [ - 'sha512' => '29ad87ff27417de3e1526517e1b8583034c9f3a47e3c1f9ff216025229f9a04c85e8bdd5551d8df6838e462' . - '71732b98400170f8fd246d47de9312df2bdde3ca9', + 'remote_file2.txt' => [ + 'filename' => self::WEBSERVER_FILES_DIR . '/remote_file2.txt', + 'checksums' => [ + 'sha512' => '29ad87ff27417de3e1526517e1b8583034c9f3a47e3c1f9ff216025229f9a04c85e8bdd5551d8df6838' . + 'e46271732b98400170f8fd246d47de9312df2bdde3ca9', + ], ], - ], - 'remote_file3.txt' => [ - 'filename' => self::WEBSERVER_FILES_DIR . '/remote_file3.txt', - 'checksums' => [ - 'sha512' => '3dccc8db74e74ba8f0d926987e6daf93f78d9d344a0babfaac5d64dd614215c5358014c830706be5f00c920' . - 'a9ce2fec0949fababfa65f3c6b7de8a3c27ac6f96', + 'remote_file3.txt' => [ + 'filename' => self::WEBSERVER_FILES_DIR . '/remote_file3.txt', + 'checksums' => [ + 'sha512' => '3dccc8db74e74ba8f0d926987e6daf93f78d9d344a0babfaac5d64dd614215c5358014c830706be5f00' . + 'c920a9ce2fec0949fababfa65f3c6b7de8a3c27ac6f96', + ], ], - ], - 'remote_file4.txt' => [ - 'filename' => self::WEBSERVER_FILES_DIR . '/remote_file4.txt', - 'checksums' => [ - 'sha512' => '6b8c5673861b4578c441cd2fe5af209d6684abdfbaea06cbafe39e9fb1c6882b790c294d19b1d61c7504a5f' . - '3a916bd4266334e7f1557a3ab0ae114b0068a8c10', + 'remote_file4.txt' => [ + 'filename' => self::WEBSERVER_FILES_DIR . '/remote_file4.txt', + 'checksums' => [ + 'sha512' => '6b8c5673861b4578c441cd2fe5af209d6684abdfbaea06cbafe39e9fb1c6882b790c294d19b1d61c750' . + '4a5f3a916bd4266334e7f1557a3ab0ae114b0068a8c10', + ], ], - ], - 'remote_image.jpg' => self::TEST_IMAGE, - ]; - - /** - * A mock webserver for some remote download tests. - * - * @var MockWebServer - */ - private static MockWebServer $webserver; - - /** - * Array of file contents for use with comparing against requests against the same index in self::$remote_urls - * - * @var array - */ - private static array $response_content = []; - - /** - * Array of mock urls to get responses from. Match response bodies against matching key in self::$response_content - * - * @var array - */ - private static array $remote_urls = []; - - /** - * {@inheritdoc} - */ - public static function setUpBeforeClass(): void - { - self::$webserver = new MockWebServer(); - self::$webserver->start(); - $counter = 0; - foreach (self::WEBSERVER_FILES as $file) { - self::$response_content[$counter] = file_get_contents($file['filename']); - self::$remote_urls[$counter] = self::$webserver->setResponseOfPath( - "/example/" . basename($file['filename']), - new Response( - self::$response_content[$counter], - [ 'Cache-Control' => 'no-cache', 'Content-Length' => stat($file['filename'])[7]], - 200 - ) - ); - $counter += 1; - } - } - - /** - * {@inheritdoc} - */ - public static function tearDownAfterClass(): void - { - self::$webserver->stop(); + 'remote_image.jpg' => self::TEST_IMAGE, + ]; + parent::setUpBeforeClass(); } /** @@ -456,7 +409,7 @@ public function testMultipleDownloadsSuccess(): void 'dir4/image.jpg', ]; $count = 0; - foreach (self::WEBSERVER_FILES as $file) { + foreach (self::$webserver_files as $file) { $hashes[] = $file['checksums']['sha512'] . " " . BagUtils::baseInData($destinations[$count]); $count += 1; } @@ -540,7 +493,7 @@ public function testMultiDownloadPartialFailure(): void ]; for ($foo = 0; $foo < 2; $foo += 1) { $f_num = $foo + 1; - $hashes[] = self::WEBSERVER_FILES["remote_file$f_num.txt"]['checksums']['sha512'] . " " . + $hashes[] = self::$webserver_files["remote_file$f_num.txt"]['checksums']['sha512'] . " " . BagUtils::baseInData($destinations[$foo]); } array_splice($hashes, 1, 0, [ diff --git a/tests/Profiles/BasicProfileTest.php b/tests/Profiles/BasicProfileTest.php index 2aa0534..581eb0d 100644 --- a/tests/Profiles/BasicProfileTest.php +++ b/tests/Profiles/BasicProfileTest.php @@ -4,22 +4,92 @@ use whikloj\BagItTools\Exceptions\ProfileException; use whikloj\BagItTools\Profiles\BagItProfile; +use whikloj\BagItTools\Profiles\ProfileFactory; use whikloj\BagItTools\Test\BagItTestFramework; +use whikloj\BagItTools\Test\BagItWebserverFramework; /** - * @coversDefaultClass \whikloj\BagItTools\Profiles\BagItProfile + * Tests of the BagItProfile and ProfileFactory class. */ -class BasicProfileTest extends BagItTestFramework +class BasicProfileTest extends BagItWebserverFramework { + private const PROFILE_DIR = self::TEST_RESOURCES . '/profiles'; + + public static function setUpBeforeClass(): void + { + self::$webserver_files = [ + 'profile_foo.json' => [ + 'filename' => self::PROFILE_DIR . '/bagProfileFoo.json', + 'headers' => [ + 'Content-Type' => 'application/json', + ], + ], + 'profile_bar.json' => [ + 'filename' => self::PROFILE_DIR . '/bagProfileBar.json', + 'headers' => [ + 'Content-Type' => 'application/json', + ], + ], + ]; + parent::setUpBeforeClass(); + } + /** * Test the first example profile from the specification. * @throws ProfileException - * @covers ::fromJson + * @covers \whikloj\BagItTools\Profiles\BagItProfile::fromJson */ public function testSpecProfileFoo(): void { $json = file_get_contents(self::TEST_RESOURCES . '/profiles/bagProfileFoo.json'); $profile = BagItProfile::fromJson($json); + $this->assertTrue($profile->isValid()); + $this->assertExampleProfileFoo($profile); + } + + /** + * Test the second example profile from the specification. + * @throws ProfileException + * @covers \whikloj\BagItTools\Profiles\BagItProfile::fromJson + */ + public function testSpecProfileBar(): void + { + $json = file_get_contents(self::TEST_RESOURCES . '/profiles/bagProfileBar.json'); + $profile = BagItProfile::fromJson($json); + $this->assertTrue($profile->isValid()); + $this->assertExampleProfileBar($profile); + } + + /** + * Test the first example profile retrieved from webserver. + * @throws ProfileException + * @covers \whikloj\BagItTools\Profiles\ProfileFactory::generateProfileFromUri + */ + public function testFactoryFoo(): void + { + $profile = ProfileFactory::generateProfileFromUri(self::$remote_urls[0]); + $this->assertTrue($profile->isValid()); + $this->assertExampleProfileFoo($profile); + } + + /** + * Test the second example profile retrieved from webserver. + * @throws ProfileException + * @covers \whikloj\BagItTools\Profiles\ProfileFactory::generateProfileFromUri + */ + public function testFactoryBar(): void + { + $profile = ProfileFactory::generateProfileFromUri(self::$remote_urls[1]); + $this->assertTrue($profile->isValid()); + $this->assertExampleProfileBar($profile); + } + + /** + * Validate the BagItProfile specification example bagProfileFoo + * @param BagItProfile $profile The profile to check. + */ + private function assertExampleProfileFoo(BagItProfile $profile): void + { $this->assertEquals( 'http://www.library.yale.edu/mssa/bagitprofiles/disk_images.json', $profile->getProfileIdentifier() @@ -78,14 +148,11 @@ public function testSpecProfileFoo(): void } /** - * Test the second example profile from the specification. - * @throws ProfileException - * @covers ::fromJson + * Validate the BagItProfile specification example bagProfileBar + * @param BagItProfile $profile The profile to check. */ - public function testSpecProfileBar(): void + private function assertExampleProfileBar(BagItProfile $profile): void { - $json = file_get_contents(self::TEST_RESOURCES . '/profiles/bagProfileBar.json'); - $profile = BagItProfile::fromJson($json); $this->assertEquals( 'http://canadiana.org/standards/bagit/tdr_ingest.json', $profile->getProfileIdentifier() From 165bb73e5cea2e7c83e2bd11c5b5713dbd2db058 Mon Sep 17 00:00:00 2001 From: Jared Whiklo Date: Thu, 11 Apr 2024 16:17:40 -0500 Subject: [PATCH 04/21] Track serializations --- composer.json | 2 +- phpunit.xml.dist | 2 +- src/Bag.php | 129 ++++++++++++++++++++++++++++++++++------------ tests/BagTest.php | 1 + 4 files changed, 100 insertions(+), 34 deletions(-) diff --git a/composer.json b/composer.json index dee7fff..64e3f28 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ "symfony/console": "^5.4" }, "require-dev": { - "phpunit/phpunit": "^9.0", + "phpunit/phpunit": "^9.6", "sebastian/phpcpd": "^6.0", "squizlabs/php_codesniffer": "^3.5", "donatj/mock-webserver": "^2.6", diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 9f81219..55974f0 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -9,7 +9,7 @@ processIsolation="false" stopOnFailure="false" bootstrap="./vendor/autoload.php" - xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"> + xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"> ./src diff --git a/src/Bag.php b/src/Bag.php index a0fff95..b3b1b50 100644 --- a/src/Bag.php +++ b/src/Bag.php @@ -119,6 +119,18 @@ class Bag */ private const BAGINFO_AUTOWRAP_GUESS_LENGTH = 70; + /** + * A map of some supported extensions to their serialization MIME types. + */ + private const SERIALIZATION_MAPPING = [ + 'bz' => 'application/x-bzip', + 'bz2' => 'application/x-bzip2', + 'gz' => 'application/gzip', + 'tgz' => 'application/gzip', + 'tar' => 'application/x-tar', + 'zip' => 'application/zip', + ]; + /** * All the extensions in one array. * @@ -236,6 +248,12 @@ class Bag */ private bool $loaded; + /** + * The serialization extension of the bag or null if not serialized. + * @var string|null + */ + private ?string $serialization_extension = null; + /** * Bag constructor. * @@ -243,13 +261,15 @@ class Bag * The path of the root of the new or existing bag. * @param boolean $new * Are we making a new bag? + * @param string|null $extension + * The compressed extension of the bag (if compressed) or null if not. * * @throws FilesystemException * Problems accessing a file. * @throws BagItException * Bag directory exists for new bag or various issues for loading an existing bag. */ - private function __construct(string $rootPath, bool $new = true) + private function __construct(string $rootPath, bool $new = true, ?string $extension = null) { $this->packageExtensions = array_merge(self::TAR_EXTENSIONS, self::ZIP_EXTENSIONS); // Define valid hash algorithms our PHP supports. @@ -268,6 +288,7 @@ private function __construct(string $rootPath, bool $new = true) if ($new) { $this->createNewBag(); } else { + $this->serialization_extension = $extension; $this->loadBag(); } } @@ -300,10 +321,15 @@ public static function create(string $rootPath): Bag public static function load(string $rootPath): Bag { $rootPath = BagUtils::getAbsolute($rootPath, true); - if (is_file($rootPath) && self::isCompressed($rootPath)) { - $rootPath = self::uncompressBag($rootPath); + $serialized_extension = null; + if (is_file($rootPath)) { + $extension = self::getExtension($rootPath); + if (self::isCompressed($extension)) { + $serialized_extension = $extension; + $rootPath = self::uncompressBag($rootPath, $serialized_extension); + } } - return new Bag($rootPath, false); + return new Bag($rootPath, false, $serialized_extension); } /** @@ -322,7 +348,7 @@ public function isValid(): bool } // Reload the bag from disk. $this->loadBag(); - if (isset($this->fetchFile)) { + if ($this->hasFetchFile()) { $this->fetchFile->downloadAll(); $this->mergeErrors($this->fetchFile->getErrors()); } @@ -374,7 +400,7 @@ public function finalize(): void { // Update files to ensure they are correct. $this->update(); - if (isset($this->fetchFile)) { + if ($this->hasFetchFile()) { // Clean up fetch files downloaded to generate checksums. $this->fetchFile->cleanup(); } @@ -938,7 +964,7 @@ private function setAlgorithmsInternal(array $algorithms): void */ public function addFetchFile(string $url, string $destination, int $size = null): void { - if (!isset($this->fetchFile)) { + if (!$this->hasFetchFile()) { $this->fetchFile = new Fetch($this, false); } $this->fetchFile->addFile($url, $destination, $size); @@ -953,7 +979,7 @@ public function addFetchFile(string $url, string $destination, int $size = null) */ public function listFetchFiles(): array { - return (!isset($this->fetchFile) ? [] : $this->fetchFile->getData()); + return (!$this->hasFetchFile() ? [] : $this->fetchFile->getData()); } /** @@ -964,7 +990,7 @@ public function listFetchFiles(): array */ public function clearFetch(): void { - if (isset($this->fetchFile)) { + if ($this->hasFetchFile()) { $this->fetchFile->clearData(); unset($this->fetchFile); $this->changed = true; @@ -981,12 +1007,20 @@ public function clearFetch(): void */ public function removeFetchFile(string $url): void { - if (isset($this->fetchFile)) { + if ($this->hasFetchFile()) { $this->fetchFile->removeFile($url); $this->changed = true; } } + /** + * @return bool Does the bag have a fetch file? + */ + public function hasFetchFile(): bool + { + return isset($this->fetchFile); + } + /** * Get the current version array or default if not specified. * @@ -1235,6 +1269,22 @@ public function removeTagFile(string $dest): void $this->changed = true; } + /** + * @return bool True if the bag was loaded from a serialized format. + */ + public function hasSerialization(): bool + { + return $this->serialization_extension !== null; + } + + /** + * @return string|null The serialization format if the bag was loaded from a serialized format or null. + */ + public function getSerializationMimeType(): ?string + { + return self::SERIALIZATION_MAPPING[$this->serialization_extension] ?? null; + } + /* * XXX: Private functions */ @@ -2076,8 +2126,10 @@ private function getParentDir(): string /** * Uncompress a BagIt archive file. * - * @param string $filepath + * @param string $filepath * The full path to the archive file. + * @param string $extension + * The extension to the archive file. * @return string * The full path to extracted bag. * @throws FilesystemException @@ -2085,14 +2137,14 @@ private function getParentDir(): string * @throws BagItException * Unable to determine correct archive format or file does not exist. */ - private static function uncompressBag(string $filepath): string + private static function uncompressBag(string $filepath, string $extension): string { if (!file_exists($filepath)) { throw new BagItException("File $filepath does not exist."); } - if (self::hasExtension($filepath, self::ZIP_EXTENSIONS)) { + if (in_array($extension, self::ZIP_EXTENSIONS)) { $directory = self::unzipBag($filepath); - } elseif (self::hasExtension($filepath, self::TAR_EXTENSIONS)) { + } elseif (in_array($extension, self::TAR_EXTENSIONS)) { $directory = self::untarBag($filepath); } else { throw new BagItException("Unable to determine archive format."); @@ -2141,7 +2193,7 @@ private static function untarBag(string $filename): string $tar = new Archive_Tar($filename, $compression); $res = $tar->extract($directory); if ($res === false) { - throw new FilesystemException("Unable to untar $filename"); + throw new FilesystemException("Unable to untar $filename : " . $tar->error_object->getMessage()); } return $directory; } @@ -2157,7 +2209,7 @@ private static function untarBag(string $filename): string private static function extensionTarCompression(string $filename): ?string { $filename = strtolower(basename($filename)); - return (str_ends_with($filename, '.bz2') ? 'bz2' : (str_ends_with($filename, '.gz') ? 'gz' : null)); + return (str_ends_with($filename, '.bz2') ? 'bz2' : (str_ends_with($filename, 'gz') ? 'gz' : null)); } /** @@ -2179,20 +2231,14 @@ private static function extractDir(): string /** * Test a filepath to see if we think it is compressed. * - * @param string $filepath - * The full path + * @param string $extension + * The file extension * @return bool * True if compressed file (we support). */ - private static function isCompressed(string $filepath): bool + private static function isCompressed(string $extension): bool { - return self::hasExtension( - $filepath, - array_merge( - self::ZIP_EXTENSIONS, - self::TAR_EXTENSIONS - ) - ); + return in_array($extension, array_merge(self::ZIP_EXTENSIONS, self::TAR_EXTENSIONS)); } /** @@ -2206,15 +2252,34 @@ private static function isCompressed(string $filepath): bool * The list of extensions or an empty array. */ private static function hasExtension(string $filepath, array $extensions): bool + { + return in_array(self::getExtension($filepath), $extensions); + } + + /** + * Retrieve all the extensions of the given filepath. + * + * @param string $filepath + * The full file path. + * @return string|null + * The extension or null if not found. + */ + private static function getExtension(string $filepath): ?string { $filename = strtolower(basename($filepath)); - foreach ($extensions as $extension) { - $extension = ".$extension"; - if (str_ends_with($filename, $extension)) { - return true; - } + $pathinfo = pathinfo($filename); + $extensions = [$pathinfo['extension']] ?? null; + while (strpos($pathinfo['filename'], ".") > -1) { + $pathinfo = pathinfo($pathinfo['filename']); + $extensions[] = $pathinfo['extension'] ?? null; + } + $extensions = array_filter($extensions); + if (count($extensions) > 0) { + $extension = implode(".", array_reverse($extensions)); + } else { + $extension = null; } - return false; + return $extension; } /** diff --git a/tests/BagTest.php b/tests/BagTest.php index b1c135a..b9c4185 100644 --- a/tests/BagTest.php +++ b/tests/BagTest.php @@ -754,6 +754,7 @@ public function testTarBag(): void $bag = Bag::load($this->tmpdir); $archivefile = $this->getTempName(); $archivefile .= ".tar"; + $this->assertTrue($bag->isValid()); $this->assertFileDoesNotExist($archivefile); $bag->package($archivefile); $this->assertFileExists($archivefile); From a60ff4b4a5226e5a7eb2ca787c804129b27ad2a4 Mon Sep 17 00:00:00 2001 From: Jared Whiklo Date: Thu, 11 Apr 2024 16:29:35 -0500 Subject: [PATCH 05/21] Load and validate profiles --- src/Bag.php | 39 ++--- src/BagUtils.php | 10 ++ src/Profiles/BagItProfile.php | 212 +++++++++++++++++++++-- tests/Profiles/BasicProfileTest.php | 17 +- tests/resources/profiles/btrProfile.json | 69 ++++++++ 5 files changed, 305 insertions(+), 42 deletions(-) create mode 100644 tests/resources/profiles/btrProfile.json diff --git a/src/Bag.php b/src/Bag.php index b3b1b50..67ad14d 100644 --- a/src/Bag.php +++ b/src/Bag.php @@ -579,7 +579,7 @@ public function getBagInfoData(): array */ public function hasBagInfoTag(string $tag): bool { - $tag = self::trimLower($tag); + $tag = BagUtils::trimLower($tag); return $this->bagInfoTagExists($tag); } @@ -593,7 +593,7 @@ public function hasBagInfoTag(string $tag): bool */ public function getBagInfoByTag(string $tag): array { - $tag = self::trimLower($tag); + $tag = BagUtils::trimLower($tag); return $this->bagInfoTagExists($tag) ? $this->bagInfoTagIndex[$tag] : []; } @@ -605,7 +605,7 @@ public function getBagInfoByTag(string $tag): array */ public function removeBagInfoTag(string $tag): void { - $tag = self::trimLower($tag); + $tag = BagUtils::trimLower($tag); if (!$this->bagInfoTagExists($tag)) { return; } @@ -632,7 +632,7 @@ public function removeBagInfoTagIndex(string $tag, int $index): void if ($index < 0) { return; } - $tag = self::trimLower($tag); + $tag = BagUtils::trimLower($tag); if (!$this->bagInfoTagExists($tag)) { return; } @@ -643,7 +643,7 @@ public function removeBagInfoTagIndex(string $tag, int $index): void $newInfo = []; $tagCount = 0; foreach ($this->bagInfoData as $row) { - $rowTag = self::trimLower($row['tag']); + $rowTag = BagUtils::trimLower($row['tag']); if ($rowTag !== $tag || $tagCount !== $index) { $newInfo[] = $row; } @@ -671,7 +671,7 @@ public function removeBagInfoTagValue(string $tag, string $value, bool $case_sen if (empty($tag) || empty($value)) { return; } - $tag = self::trimLower($tag); + $tag = BagUtils::trimLower($tag); if (!$this->hasBagInfoTag($tag)) { return; } @@ -697,7 +697,7 @@ public function removeBagInfoTagValue(string $tag, string $value, bool $case_sen public function addBagInfoTag(string $tag, string $value): void { $this->setExtended(true); - $internal_tag = self::trimLower($tag); + $internal_tag = BagUtils::trimLower($tag); if (in_array($internal_tag, self::BAG_INFO_GENERATED_ELEMENTS)) { throw new BagItException("Field $tag is auto-generated and cannot be manually set."); } @@ -716,7 +716,7 @@ public function addBagInfoTags(array $tags): void { $this->setExtended(true); $normalized_keys = array_keys($tags); - $normalized_keys = array_map(self::class . '::trimLower', $normalized_keys); + $normalized_keys = array_map('whikloj\BagItTools\BagUtils::trimLower', $normalized_keys); $overlap = array_intersect($normalized_keys, self::BAG_INFO_GENERATED_ELEMENTS); if (count($overlap) !== 0) { throw new BagItException( @@ -735,7 +735,7 @@ public function addBagInfoTags(array $tags): void private function addBagInfoTagsInternal(array $tags): void { foreach ($tags as $key => $value) { - $internal_key = self::trimLower($key); + $internal_key = BagUtils::trimLower($key); if (!$this->bagInfoTagExists($internal_key)) { $this->bagInfoTagIndex[$internal_key] = []; } @@ -764,7 +764,7 @@ private function addBagInfoTagsInternal(array $tags): void */ public function setFileEncoding(string $encoding): void { - $encoding = self::trimLower($encoding); + $encoding = BagUtils::trimLower($encoding); $charset = BagUtils::getValidCharset($encoding); if (is_null($charset)) { throw new BagItException("Character set $encoding is not supported."); @@ -1479,19 +1479,6 @@ private function loadBagInfo(): bool return true; } - /** - * Return a trimmed and lowercase version of text. - * - * @param string $text - * The original text. - * @return string - * The lowercase trimmed text. - */ - private static function trimLower(string $text): string - { - return trim(strtolower($text)); - } - /** * Just trim spaces NOT newlines and carriage returns. * @param string $text @@ -1511,7 +1498,7 @@ private function updateBagInfoIndex(): void { $tags = []; foreach ($this->bagInfoData as $row) { - $tagName = self::trimLower($row['tag']); + $tagName = BagUtils::trimLower($row['tag']); if (!array_key_exists($tagName, $tags)) { $tags[$tagName] = []; } @@ -1569,7 +1556,7 @@ private function updateCalculateBagInfoFields(): void { $newInfo = []; foreach ($this->bagInfoData as $row) { - if (in_array(self::trimLower($row['tag']), self::BAG_INFO_GENERATED_ELEMENTS)) { + if (in_array(BagUtils::trimLower($row['tag']), self::BAG_INFO_GENERATED_ELEMENTS)) { continue; } $newInfo[] = $row; @@ -2398,7 +2385,7 @@ private static function filterPhpHashAlgorithms(string $item): bool */ public static function getHashName(string $algorithm): string { - $algorithm = Bag::trimLower($algorithm); + $algorithm = BagUtils::trimLower($algorithm); $algorithm = preg_replace("/[^a-z0-9]/", "", $algorithm); return in_array($algorithm, array_keys(Bag::HASH_ALGORITHMS)) ? $algorithm : ""; } diff --git a/src/BagUtils.php b/src/BagUtils.php index 6bfa77c..be8b1e6 100644 --- a/src/BagUtils.php +++ b/src/BagUtils.php @@ -424,6 +424,16 @@ public static function standardizePathSeparators(string $path): string return str_replace('\\', '/', $path); } + /** + * Utility function to trim and lowercase a string. + * @param string $string The string to standardize. + * @return string The standardized string. + */ + public static function trimLower(string $string): string + { + return strtolower(trim($string)); + } + /** * Walk up a path as far as the rootDir and delete empty directories. * @param string $path The path to check. diff --git a/src/Profiles/BagItProfile.php b/src/Profiles/BagItProfile.php index 4649412..c0645c9 100644 --- a/src/Profiles/BagItProfile.php +++ b/src/Profiles/BagItProfile.php @@ -5,6 +5,8 @@ namespace whikloj\BagItTools\Profiles; use Exception; +use whikloj\BagItTools\Bag; +use whikloj\BagItTools\BagUtils; use whikloj\BagItTools\Exceptions\ProfileException; /** @@ -71,6 +73,11 @@ class BagItProfile */ protected array $profileBagInfoTags = []; + /** + * @var array A list of "required" BagInfo tags. + */ + protected array $requiredBagInfoTags = []; + /** * @var array * The list of required manifest algorithms. e.g. ["sha1", "md5"]. @@ -347,7 +354,11 @@ private function setBagInfoTags(array $bagInfoTags): BagItProfile $this->profileWarnings[] = "The tag BagIt-Profile-Identifier is always required, but SHOULD NOT be listed under Bag-Info in the Profile."; } else { - $this->profileBagInfoTags[$tagName] = ProfileTags::fromJson($tagName, $tagOpts); + $profileTag = ProfileTags::fromJson($tagName, $tagOpts); + $this->profileBagInfoTags[BagUtils::trimLower($tagName)] = $profileTag; + if ($profileTag->isRequired()) { + $this->requiredBagInfoTags[] = BagUtils::trimLower($tagName); + } } } return $this; @@ -582,21 +593,33 @@ private function setTagFilesAllowed(array $tagFilesAllowed): BagItProfile } /** - * Assert that the array of required paths are covered by the array of allowed paths. - * @param array $required The list of required paths. - * @param array $allowed The list of allowed paths. - * @return bool True if all required paths are covered by allowed paths. + * Assert that the array of paths are covered by the array of allowed paths and glob style patterns. + * @param array $paths The list of paths. + * @param array $allowed The list of allowed paths, and glob style patterns. + * @return bool True if all paths are covered by allowed paths/patterns. */ - private function isRequiredPathsCoveredByAllowed(array $required, array $allowed): bool + private function isRequiredPathsCoveredByAllowed(array $paths, array $allowed): bool { - if (count($required) === 0 || count($allowed) === 0) { + if (count($paths) === 0 || count($allowed) === 0) { return true; } - $perfect_match = array_intersect($required, $allowed); - if (count($perfect_match) === count($required)) { + $perfect_match = array_intersect($paths, $allowed); + if (count($perfect_match) === count($paths)) { return true; } - $remaining = array_diff($required, $perfect_match); + return $this->getPathsNotCoveredByAllowed($paths, $allowed) === []; + } + + /** + * Get the list of paths that are not covered by the allowed paths and glob style patterns. + * @param array $paths The list of paths. + * @param array $allowed The list of allowed paths and glob style patterns. + * @return array The list of paths not covered by allowed paths/patterns. + */ + private function getPathsNotCoveredByAllowed(array $paths, array $allowed): array + { + $perfect_match = array_intersect($paths, $allowed); + $remaining = array_diff($paths, $perfect_match); foreach ($allowed as $allowedFile) { $regex = $this->convertGlobToRegex($allowedFile); $matching = array_filter($remaining, function ($tagFile) use ($regex) { @@ -606,10 +629,10 @@ private function isRequiredPathsCoveredByAllowed(array $required, array $allowed $remaining = array_diff($remaining, $matching); } if (count($remaining) === 0) { - return true; + return []; } } - return false; + return $remaining; } /** @@ -670,7 +693,7 @@ private function setPayloadFilesAllowed(array $payloadFilesAllowed): BagItProfil */ private static function matchStrings(string $expected, ?string $provided): bool { - return ($provided !== null && strtolower(trim($expected)) === strtolower(trim($provided))); + return ($provided !== null && BagUtils::trimLower($expected) === BagUtils::trimLower($provided)); } /** @@ -822,4 +845,167 @@ public function isValid(): bool } return true; } + + /** + * Validate a bag against this profile. + * @param Bag $bag The bag to validate. + * @return bool True if the bag is valid. + * @throws ProfileException If the bag is not valid. + */ + public function validateBag(Bag $bag): bool + { + $errors = []; + $warnings = []; + if (count($this->requiredBagInfoTags) > 0 && !$bag->isExtended()) { + $errors[] = "Profile requires Bag-Info tags but the Bag is not extended"; + } + foreach ($this->getBagInfoTags() as $requiredTag => $infoTag) { + if ($infoTag->isRequired() && !$bag->hasBagInfoTag($requiredTag)) { + $errors[] = "Profile requires tag ($requiredTag) which is missing from the bag"; + } + if ( + !$infoTag->isRepeatable() && + $bag->hasBagInfoTag($requiredTag) && + count($bag->getBagInfoByTag($requiredTag)) > 1 + ) { + $errors[] = "Profile does not allow tag ($requiredTag) to repeat, there are " . + count($bag->getBagInfoByTag($requiredTag)) . " values in the bag"; + } + if ($infoTag->getValues() !== [] && $bag->hasBagInfoTag($requiredTag)) { + $diff = array_diff($bag->getBagInfoByTag($requiredTag), $infoTag->getValues()); + if ($diff !== []) { + $errors[] = "Profile requires tag ($requiredTag) to have value(s) (" . + implode(", ", $infoTag->getValues()) . ") but the bag has value(s) (" . + implode(", ", $diff) . ")"; + } + } + } + if (!$this->isAllowFetchTxt() && $bag->hasFetchFile()) { + $errors[] = "Profile does not allow fetch.txt but the bag has one"; + } + if ($this->isRequireFetchTxt() && !$bag->hasFetchFile()) { + $errors[] = "Profile requires fetch.txt but the bag does not have one"; + } + if ($this->isDataEmpty()) { + $manifests = $bag->getPayloadManifests()[0]; + $hashes = $manifests->getHashes(); + if (count($hashes) > 1) { + $errors[] = "Profile requires /data directory to be empty or contain a single 0 byte file but it" . + "contains " . count($hashes) . " files"; + } elseif (count($hashes) == 1) { + $file = reset($hashes); + if (stat($file)['size'] > 0) { + $errors[] = "Profile requires /data directory to be empty or contain a single 0 byte file but it" . + "contains a single file of size " . stat($file)['size']; + } + } + } + if ($this->getSerialization() === 'required') { + if ($bag->getSerializationMimeType() === null) { + $errors[] = "Profile requires serialization MIME type but the bag has none"; + } elseif (!in_array($bag->getSerializationMimeType(), $this->getAcceptSerialization())) { + $errors[] = "Profile requires serialization MIME type (" . + implode(", ", $this->getAcceptSerialization()) . + ") but the bag has MIME type (" . $bag->getSerializationMimeType() . ")"; + } + } elseif ($this->getSerialization() === 'forbidden' && $bag->getSerializationMimeType() !== null) { + $errors[] = "Profile forbids serialization MIME type but the bag has MIME type (" . + $bag->getSerializationMimeType() . ")"; + } elseif ( + $this->getSerialization() === 'optional' && + $bag->getSerializationMimeType() !== null && + !in_array($bag->getSerializationMimeType(), $this->getAcceptSerialization()) + ) { + $errors[] = "Profile allows for serialization MIME type (" . + implode(", ", $this->getAcceptSerialization()) . + ") but the bag has MIME type (" . $bag->getSerializationMimeType() . ")"; + } + if ( + $this->getAcceptBagItVersion() !== [] && + !in_array($bag->getVersionString(), $this->getAcceptBagItVersion()) + ) { + $errors[] = "Profile requires BagIt version of (" . implode(", ", $this->getAcceptBagItVersion()) . + ") but the bag has version (" . $bag->getVersionString() . ")"; + } + if ($this->getManifestsRequired() !== []) { + $manifests = array_keys($bag->getPayloadManifests()); + $diff = array_diff($manifests, $this->getManifestsRequired()) + + array_diff($this->getManifestsRequired(), $manifests); + if ($diff !== []) { + $errors[] = "Profile requires payload manifest(s) which are missing from the bag (" . + implode(", ", $diff) . ")"; + } + } + if ($this->getManifestsAllowed() !== []) { + $manifests = array_keys($bag->getPayloadManifests()); + $diff = array_diff($manifests, $this->getManifestsAllowed()); + if ($diff !== []) { + $errors[] = "Profile allows payload manifest(s) (" . implode(", ", $this->getManifestsAllowed()) . + "), but the bag has manifest(s) (" . implode(", ", $diff) . ") which are not allowed"; + } + } + if ($this->getTagManifestsRequired() !== []) { + $manifests = array_keys($bag->getTagManifests()); + $diff = array_diff($manifests, $this->getTagManifestsRequired()) + + array_diff($this->getTagManifestsRequired(), $manifests); + if ($diff !== []) { + $errors[] = "Profile requires tag manifest(s) which are missing from the bag (" . + implode(", ", $diff) . ")"; + } + } + if ($this->getTagManifestsAllowed() !== []) { + $manifests = array_keys($bag->getTagManifests()); + $diff = array_diff($manifests, $this->getTagManifestsAllowed()); + if ($diff !== []) { + $errors[] = "Profile allows tag manifest(s) (" . implode(", ", $this->getTagManifestsAllowed()) . + "), but the bag has manifest(s) (" . implode(", ", $diff) . ") which are not allowed"; + } + } + if ($this->getTagFilesRequired() !== []) { + // Grab the first tag manifest, they should all be the same + $manifests = $bag->getTagManifests()[0]; + $tag_files = array_keys($manifests->getHashes()); + $diff = array_diff($this->getTagFilesRequired(), $tag_files) + + array_diff($tag_files, $this->getTagFilesRequired()); + if ($diff !== []) { + $errors[] = "Profile requires tag files(s) which are missing from the bag (" . + implode(", ", $diff) . ")"; + } + } + if ($this->getTagFilesAllowed() !== []) { + // Grab the first tag manifest, they should all be the same + $manifests = $bag->getTagManifests()[0]; + $tag_files = array_keys($manifests->getHashes()); + $diff = $this->getPathsNotCoveredByAllowed($tag_files, $this->getTagFilesAllowed()); + if ($diff !== []) { + $errors[] = "Profile allows tag files(s) (" . implode(", ", $this->getTagFilesAllowed()) . + "), but the bag has manifest(s) (" . implode(", ", $diff) . ") which are not allowed"; + } + } + if ($this->getPayloadFilesRequired() !== []) { + // Grab the first tag manifest, they should all be the same + $manifests = $bag->getPayloadManifests()[0]; + $payload_files = array_keys($manifests->getHashes()); + $diff = array_diff($this->getPayloadFilesRequired(), $payload_files) + + array_diff($payload_files, $this->getPayloadFilesRequired()); + if ($diff !== []) { + $errors[] = "Profile requires payload file(s) which are missing from the bag (" . + implode(", ", $diff) . ")"; + } + } + if ($this->getPayloadFilesAllowed() !== []) { + // Grab the first tag manifest, they should all be the same + $manifests = $bag->getPayloadManifests()[0]; + $tag_files = array_keys($manifests->getHashes()); + $diff = $this->getPathsNotCoveredByAllowed($tag_files, $this->getPayloadFilesAllowed()); + if ($diff !== []) { + $errors[] = "Profile allows payload files(s) (" . implode(", ", $this->getPayloadFilesAllowed()) . + "), but the bag has file(s) (" . implode(", ", $diff) . ") which are not allowed"; + } + } + if (count($errors) > 0) { + throw new ProfileException(implode("\n", $errors)); + } + return true; + } } diff --git a/tests/Profiles/BasicProfileTest.php b/tests/Profiles/BasicProfileTest.php index 581eb0d..5eb4a82 100644 --- a/tests/Profiles/BasicProfileTest.php +++ b/tests/Profiles/BasicProfileTest.php @@ -2,10 +2,10 @@ namespace whikloj\BagItTools\Test\Profiles; +use whikloj\BagItTools\Bag; use whikloj\BagItTools\Exceptions\ProfileException; use whikloj\BagItTools\Profiles\BagItProfile; use whikloj\BagItTools\Profiles\ProfileFactory; -use whikloj\BagItTools\Test\BagItTestFramework; use whikloj\BagItTools\Test\BagItWebserverFramework; /** @@ -295,6 +295,17 @@ private function assertExampleProfileBar(BagItProfile $profile): void $this->assertArrayEquals([], $profile->getPayloadFilesAllowed()); } + public function testValidateBag1(): void + { + $profile = ProfileFactory::generateProfileFromUri(self::$remote_urls[0]); + $this->assertTrue($profile->isValid()); + $this->tmpdir = $this->prepareExtendedTestBag(); + $bag = Bag::load($this->tmpdir); + $this->assertTrue($bag->isValid()); + $this->expectException(ProfileException::class); + $profile->validateBag($bag); + } + /** * Assert the bag-info tags are as expected. * @param array $expected The expected tags. @@ -303,8 +314,8 @@ private function assertExampleProfileBar(BagItProfile $profile): void private function assertProfileBagInfoTags(array $expected, BagItProfile $profile): void { foreach ($expected as $tag => $value) { - $this->assertArrayHasKey($tag, $profile->getBagInfoTags()); - $profileTag = $profile->getBagInfoTags()[$tag]; + $this->assertArrayHasKey(strtolower($tag), $profile->getBagInfoTags()); + $profileTag = $profile->getBagInfoTags()[strtolower($tag)]; $this->assertEquals($value['required'], $profileTag->isRequired()); $this->assertEquals($value['repeatable'], $profileTag->isRepeatable()); $this->assertArrayEquals($value['values'], $profileTag->getValues()); diff --git a/tests/resources/profiles/btrProfile.json b/tests/resources/profiles/btrProfile.json new file mode 100644 index 0000000..7184f98 --- /dev/null +++ b/tests/resources/profiles/btrProfile.json @@ -0,0 +1,69 @@ +{ + "BagIt-Profile-Info": { + "Source-Organization": "Beyond the Repository Bagit Profile Group", + "External-Description": "Bagit Profile for Consistent Deposit to Distributed Digital Preservation Services", + "Version": "1.0", + "BagIt-Profile-Identifier": "https://github.com/dpscollaborative/btr_bagit_profile/releases/download/1.0/btr-bagit-profile.json", + "BagIt-Profile-Version": "1.3.0" + }, + "Bag-Info": { + "Source-Organization": {"required": true}, + "Bagging-Date": {"required": true}, + "Payload-Oxum": {"required": true}, + "Organization-Address": {"required": false, "recommended": true}, + "Contact-Name": {"required": false}, + "Contact-Phone": {"required": false}, + "Contact-Email": {"required": false, "recommended": true}, + "External-Description": {"required": false}, + "External-Identifier": {"required": false}, + "Bag-Group-Identifier": { + "required": false, + "recommended": true, + "description": "*Recommended in the case of multiple related bags, otherwise optional" + }, + "Bag-Count": { + "required": false, + "recommended": true, + "description": "*Recommended in the case of multiple related bags, otherwise optional" + }, + "Bag-Size": {"required": false}, + "Internal-Sender-Identifier": {"required": false, "recommended": true}, + "Internal-Sender-Description": {"required": false, "recommended": true}, + "Payload-Identifier": {"required": false}, + "Bag-Producing-Organization": { + "required": false, + "recommended": true, + "description": "Can be the same as source_organization recommended when not the same as source" + } + }, + "Manifests-Required": [], + "Manifests-Allowed": [ + "md5", + "sha1", + "sha256", + "sha512" + ], + "Allow-Fetch.txt": false, + "Serialization": "optional", + "Accept-Serialization": [ + "application/zip", + "application/tar", + "application/x-tar", + "application/gzip", + "application/x-gzip", + "application/x-7z-compressed" + ], + "Tag-Manifests-Required": [], + "Tag-Manifests-Allowed": [ + "md5", + "sha1", + "sha256", + "sha512" + ], + "Tag-Files-Required": [], + "Tag-Files-Allowed": ["*"], + "Accept-BagIt-Version": [ + "0.97", + "1.0" + ] +} From f4cd3888705b39c0dfb3150a1780e565c3876dee Mon Sep 17 00:00:00 2001 From: Jared Whiklo Date: Fri, 12 Apr 2024 08:45:36 -0500 Subject: [PATCH 06/21] Return file path in archive type exception --- src/Bag.php | 11 +++++------ tests/BagTest.php | 4 ++-- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/Bag.php b/src/Bag.php index 67ad14d..81892f1 100644 --- a/src/Bag.php +++ b/src/Bag.php @@ -418,7 +418,7 @@ public function package(string $filepath): void { if (!self::hasExtension($filepath, $this->packageExtensions)) { throw new BagItException( - "Unknown archive type, the file extension must be one of (" . + "Unknown archive type ($filepath), the file extension must be one of (" . implode(", ", $this->packageExtensions) . ")" ); } @@ -2255,18 +2255,17 @@ private static function getExtension(string $filepath): ?string { $filename = strtolower(basename($filepath)); $pathinfo = pathinfo($filename); - $extensions = [$pathinfo['extension']] ?? null; + $extensions = []; + $extensions[] = $pathinfo['extension'] ?? null; while (strpos($pathinfo['filename'], ".") > -1) { $pathinfo = pathinfo($pathinfo['filename']); $extensions[] = $pathinfo['extension'] ?? null; } $extensions = array_filter($extensions); if (count($extensions) > 0) { - $extension = implode(".", array_reverse($extensions)); - } else { - $extension = null; + return implode(".", array_reverse($extensions)); } - return $extension; + return null; } /** diff --git a/tests/BagTest.php b/tests/BagTest.php index b9c4185..95666fd 100644 --- a/tests/BagTest.php +++ b/tests/BagTest.php @@ -838,8 +838,8 @@ public function testInvalidPackage(): void $this->assertFileDoesNotExist($archivefile); $this->expectException(BagItException::class); - $this->expectExceptionMessage("Unknown archive type, the file extension must be one of (tar, tgz, tar.gz, " . - "tar.bz2, zip)"); + $this->expectExceptionMessage("Unknown archive type ($archivefile), the file extension must be one of (tar, " . + "tgz, tar.gz, tar.bz2, zip)"); $bag->package($archivefile); } From d67ed6607d764851330a2f503a8ecbddb0322799 Mon Sep 17 00:00:00 2001 From: Jared Whiklo Date: Fri, 12 Apr 2024 08:57:44 -0500 Subject: [PATCH 07/21] Windows test --- src/Bag.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Bag.php b/src/Bag.php index 81892f1..5f23d85 100644 --- a/src/Bag.php +++ b/src/Bag.php @@ -2255,12 +2255,14 @@ private static function getExtension(string $filepath): ?string { $filename = strtolower(basename($filepath)); $pathinfo = pathinfo($filename); + var_dump($pathinfo); $extensions = []; $extensions[] = $pathinfo['extension'] ?? null; while (strpos($pathinfo['filename'], ".") > -1) { $pathinfo = pathinfo($pathinfo['filename']); $extensions[] = $pathinfo['extension'] ?? null; } + var_dump($extensions); $extensions = array_filter($extensions); if (count($extensions) > 0) { return implode(".", array_reverse($extensions)); From f4492705721e29e290f3e03b86c6c9b2f0c56c69 Mon Sep 17 00:00:00 2001 From: Jared Whiklo Date: Fri, 12 Apr 2024 10:20:08 -0500 Subject: [PATCH 08/21] Clean up extension handling --- composer.json | 3 ++- src/Bag.php | 59 ++++++++++++++++++++++------------------------- tests/BagTest.php | 38 +++++++++++++++++++++++------- 3 files changed, 59 insertions(+), 41 deletions(-) diff --git a/composer.json b/composer.json index 64e3f28..f928f5a 100644 --- a/composer.json +++ b/composer.json @@ -54,7 +54,8 @@ ], "test": [ "@check", - "@phpunit" + "@phpunit", + "@phpstan" ] }, "config": { diff --git a/src/Bag.php b/src/Bag.php index 5f23d85..f105556 100644 --- a/src/Bag.php +++ b/src/Bag.php @@ -96,17 +96,17 @@ class Bag * Extensions which map to a tar file. */ private const TAR_EXTENSIONS = [ - 'tar', - 'tgz', - 'tar.gz', - 'tar.bz2', + '.tar', + '.tgz', + '.tar.gz', + '.tar.bz2', ]; /** * Extensions which map to a zip file. */ private const ZIP_EXTENSIONS = [ - 'zip', + '.zip', ]; /** @@ -324,7 +324,7 @@ public static function load(string $rootPath): Bag $serialized_extension = null; if (is_file($rootPath)) { $extension = self::getExtension($rootPath); - if (self::isCompressed($extension)) { + if (self::hasExtension($extension, array_merge(self::ZIP_EXTENSIONS, self::TAR_EXTENSIONS))) { $serialized_extension = $extension; $rootPath = self::uncompressBag($rootPath, $serialized_extension); } @@ -416,7 +416,7 @@ public function finalize(): void */ public function package(string $filepath): void { - if (!self::hasExtension($filepath, $this->packageExtensions)) { + if (!self::hasExtension(self::getExtension($filepath), $this->packageExtensions)) { throw new BagItException( "Unknown archive type ($filepath), the file extension must be one of (" . implode(", ", $this->packageExtensions) . ")" @@ -2047,9 +2047,10 @@ private function loadFetch(): void */ private function makePackage(string $filename): void { - if (self::hasExtension($filename, self::ZIP_EXTENSIONS)) { + $extension = self::getExtension($filename); + if (self::hasExtension($extension, self::ZIP_EXTENSIONS)) { $this->makeZip($filename); - } elseif (self::hasExtension($filename, self::TAR_EXTENSIONS)) { + } elseif (self::hasExtension($extension, self::TAR_EXTENSIONS)) { $this->makeTar($filename); } else { throw new BagItException("Unable to determine archive format."); @@ -2129,9 +2130,9 @@ private static function uncompressBag(string $filepath, string $extension): stri if (!file_exists($filepath)) { throw new BagItException("File $filepath does not exist."); } - if (in_array($extension, self::ZIP_EXTENSIONS)) { + if (self::hasExtension($extension, self::ZIP_EXTENSIONS)) { $directory = self::unzipBag($filepath); - } elseif (in_array($extension, self::TAR_EXTENSIONS)) { + } elseif (self::hasExtension($extension, self::TAR_EXTENSIONS)) { $directory = self::untarBag($filepath); } else { throw new BagItException("Unable to determine archive format."); @@ -2216,31 +2217,27 @@ private static function extractDir(): string } /** - * Test a filepath to see if we think it is compressed. + * Determine whether the given file extensions ends with an accepted extensions * - * @param string $extension - * The file extension - * @return bool - * True if compressed file (we support). - */ - private static function isCompressed(string $extension): bool - { - return in_array($extension, array_merge(self::ZIP_EXTENSIONS, self::TAR_EXTENSIONS)); - } - - /** - * Retrieve whether the given filepath has one of the extensions - * - * @param string $filepath - * The full file path. + * @param string|null $file_extension + * The file extensions. * @param array $extensions * The list of extensions to check. * @return bool * The list of extensions or an empty array. */ - private static function hasExtension(string $filepath, array $extensions): bool + private static function hasExtension(?string $file_extension, array $extensions): bool { - return in_array(self::getExtension($filepath), $extensions); + if (is_null($file_extension)) { + return false; + } + foreach ($extensions as $extension) { + // Need to loop and check each to avoid failing on foo.tmp.tar.gz or bar.old.zip + if (str_ends_with($file_extension, $extension)) { + return true; + } + } + return false; } /** @@ -2255,17 +2252,15 @@ private static function getExtension(string $filepath): ?string { $filename = strtolower(basename($filepath)); $pathinfo = pathinfo($filename); - var_dump($pathinfo); $extensions = []; $extensions[] = $pathinfo['extension'] ?? null; while (strpos($pathinfo['filename'], ".") > -1) { $pathinfo = pathinfo($pathinfo['filename']); $extensions[] = $pathinfo['extension'] ?? null; } - var_dump($extensions); $extensions = array_filter($extensions); if (count($extensions) > 0) { - return implode(".", array_reverse($extensions)); + return "." . ltrim(implode(".", array_reverse($extensions)), ".\ \n\r\t\v\0"); } return null; } diff --git a/tests/BagTest.php b/tests/BagTest.php index 95666fd..46a66e1 100644 --- a/tests/BagTest.php +++ b/tests/BagTest.php @@ -627,7 +627,7 @@ public function testWarningOnMd5(): void * @group Bag * @covers ::load * @covers ::hasExtension - * @covers ::isCompressed + * @covers ::getExtension */ public function testNonExistantCompressed(): void { @@ -644,7 +644,7 @@ public function testNonExistantCompressed(): void * Test opening a tar gzip * @group Bag * @covers ::load - * @covers ::isCompressed + * @covers ::getExtension * @covers ::uncompressBag * @covers ::hasExtension * @covers ::untarBag @@ -668,7 +668,7 @@ public function testUncompressTarGz(): void * Test opening a tar bzip2. * @group Bag * @covers ::load - * @covers ::isCompressed + * @covers ::getExtension * @covers ::uncompressBag * @covers ::hasExtension * @covers ::untarBag @@ -691,7 +691,7 @@ public function testUncompressTarBzip(): void /** * Test opening a zip file. * @group Bag - * @covers ::isCompressed + * @covers ::getExtension * @covers ::uncompressBag * @covers ::hasExtension * @covers ::unzipBag @@ -711,6 +711,30 @@ public function testUncompressZip(): void ); } + /** + * Test a valid archive with an invalid extension included + * + * @group Bag + * @covers ::getExtension + * @covers ::hasExtension + */ + public function testInvalidExtension(): void + { + $this->tmpdir = $this->prepareBasicTestBag(); + $bag = Bag::load($this->tmpdir); + $archivefile = $this->getTempName(); + $archivefile .= ".tmp.zip"; + $this->assertFileDoesNotExist($archivefile); + $bag->package($archivefile); + $this->assertFileExists($archivefile); + $newbag = Bag::load($archivefile); + $this->assertTrue($newbag->isValid()); + $this->assertEquals( + $bag->getPayloadManifests()['sha256']->getHashes(), + $newbag->getPayloadManifests()['sha256']->getHashes() + ); + } + /** * Test generating a zip. * @@ -729,10 +753,8 @@ public function testZipBag(): void $this->assertFileDoesNotExist($archivefile); $bag->package($archivefile); $this->assertFileExists($archivefile); - $newbag = Bag::load($archivefile); $this->assertTrue($newbag->isValid()); - $this->assertEquals( $bag->getPayloadManifests()['sha256']->getHashes(), $newbag->getPayloadManifests()['sha256']->getHashes() @@ -838,8 +860,8 @@ public function testInvalidPackage(): void $this->assertFileDoesNotExist($archivefile); $this->expectException(BagItException::class); - $this->expectExceptionMessage("Unknown archive type ($archivefile), the file extension must be one of (tar, " . - "tgz, tar.gz, tar.bz2, zip)"); + $this->expectExceptionMessage("Unknown archive type ($archivefile), the file extension must be one of (.tar, " . + ".tgz, .tar.gz, .tar.bz2, .zip)"); $bag->package($archivefile); } From 8139ff38911857951586da82b3f0ae43310cd502 Mon Sep 17 00:00:00 2001 From: Jared Whiklo Date: Fri, 12 Apr 2024 14:55:20 -0500 Subject: [PATCH 09/21] Add more profile tests --- src/Bag.php | 45 ++- tests/BagInternalTest.php | 37 ++ tests/Profiles/BagItProfileBarTest.php | 159 ++++++++ tests/Profiles/BagItProfileFooTest.php | 82 ++++ tests/Profiles/BagProfileTest.php | 32 ++ tests/Profiles/BasicProfileTest.php | 325 ---------------- tests/Profiles/ProfileTestFramework.php | 495 ++++++++++++++++++++++++ 7 files changed, 835 insertions(+), 340 deletions(-) create mode 100644 tests/Profiles/BagItProfileBarTest.php create mode 100644 tests/Profiles/BagItProfileFooTest.php create mode 100644 tests/Profiles/BagProfileTest.php delete mode 100644 tests/Profiles/BasicProfileTest.php create mode 100644 tests/Profiles/ProfileTestFramework.php diff --git a/src/Bag.php b/src/Bag.php index f105556..8e20c7f 100644 --- a/src/Bag.php +++ b/src/Bag.php @@ -123,12 +123,12 @@ class Bag * A map of some supported extensions to their serialization MIME types. */ private const SERIALIZATION_MAPPING = [ - 'bz' => 'application/x-bzip', - 'bz2' => 'application/x-bzip2', - 'gz' => 'application/gzip', - 'tgz' => 'application/gzip', - 'tar' => 'application/x-tar', - 'zip' => 'application/zip', + '.bz' => 'application/x-bzip', + '.bz2' => 'application/x-bzip2', + '.gz' => 'application/gzip', + '.tgz' => 'application/gzip', + '.tar' => 'application/x-tar', + '.zip' => 'application/zip', ]; /** @@ -249,10 +249,10 @@ class Bag private bool $loaded; /** - * The serialization extension of the bag or null if not serialized. + * The serialization mime-type of the bag or null if not serialized. * @var string|null */ - private ?string $serialization_extension = null; + private ?string $serialization = null; /** * Bag constructor. @@ -262,7 +262,7 @@ class Bag * @param boolean $new * Are we making a new bag? * @param string|null $extension - * The compressed extension of the bag (if compressed) or null if not. + * The extension of the bag (if it was serialized) or null if not. * * @throws FilesystemException * Problems accessing a file. @@ -288,7 +288,7 @@ private function __construct(string $rootPath, bool $new = true, ?string $extens if ($new) { $this->createNewBag(); } else { - $this->serialization_extension = $extension; + $this->serialization = is_null($extension) ? null : $this->determineSerializationMimetype($extension); $this->loadBag(); } } @@ -321,15 +321,14 @@ public static function create(string $rootPath): Bag public static function load(string $rootPath): Bag { $rootPath = BagUtils::getAbsolute($rootPath, true); - $serialized_extension = null; + $extension = null; if (is_file($rootPath)) { $extension = self::getExtension($rootPath); if (self::hasExtension($extension, array_merge(self::ZIP_EXTENSIONS, self::TAR_EXTENSIONS))) { - $serialized_extension = $extension; - $rootPath = self::uncompressBag($rootPath, $serialized_extension); + $rootPath = self::uncompressBag($rootPath, $extension); } } - return new Bag($rootPath, false, $serialized_extension); + return new Bag($rootPath, false, $extension); } /** @@ -1282,7 +1281,7 @@ public function hasSerialization(): bool */ public function getSerializationMimeType(): ?string { - return self::SERIALIZATION_MAPPING[$this->serialization_extension] ?? null; + return $this->serialization; } /* @@ -2525,4 +2524,20 @@ private function mergeWarnings(array $newWarnings): void { $this->bagWarnings = array_merge($this->bagWarnings, $newWarnings); } + + /** + * Determine the serialization mimetype from the extension. + * @param string $extension The extension. + * @return string The serialization mimetype. + * @throws BagItException If the serialization mimetype cannot be determined. + */ + private function determineSerializationMimetype(string $extension): string + { + foreach (self::SERIALIZATION_MAPPING as $extension => $mimetype) { + if (str_ends_with($extension, $extension)) { + return $mimetype; + } + } + throw new BagItException("Unable to determine serialization mimetype for extension ($extension)."); + } } diff --git a/tests/BagInternalTest.php b/tests/BagInternalTest.php index 524387b..a9fa9a9 100644 --- a/tests/BagInternalTest.php +++ b/tests/BagInternalTest.php @@ -300,4 +300,41 @@ public function testCheckFilePathEncoding(): void $methodCall->invokeArgs($payload, ["succeed-for-%0D-filename.txt", 1]); $this->assertCount(2, $loadIssues->getValue($payload)['error']); } + + /** + * @group Internal + * @covers \whikloj\BagItTools\Bag::hasExtension + */ + public function testHasExtension(): void + { + $bag = Bag::create($this->tmpdir); + $class = new ReflectionClass('whikloj\BagItTools\Bag'); + $methodCall = $class->getMethod('hasExtension'); + $methodCall->setAccessible(true); + + $this->assertTrue($methodCall->invokeArgs($bag, ['file.txt', ['.txt', '.jpg']])); + $this->assertTrue($methodCall->invokeArgs($bag, ['file.old.txt', ['.txt', '.jpg']])); + $this->assertTrue($methodCall->invokeArgs($bag, ['file.jpg', ['.txt', '.jpg']])); + $this->assertTrue($methodCall->invokeArgs($bag, ['file.jpg.txt', ['.txt', '.jpg']])); + + $this->assertFalse($methodCall->invokeArgs($bag, ['file.jpg.old', ['.txt', '.jpg']])); + $this->assertFalse($methodCall->invokeArgs($bag, ['file.old', ['.txt', '.jpg']])); + } + + /** + * @group Internal + * @covers \whikloj\BagItTools\Bag::getExtension + */ + public function testGetExtension(): void + { + $bag = Bag::create($this->tmpdir); + $class = new ReflectionClass('whikloj\BagItTools\Bag'); + $methodCall = $class->getMethod('getExtension'); + $methodCall->setAccessible(true); + + $this->assertEquals('.txt', $methodCall->invokeArgs($bag, ['file.txt'])); + $this->assertEquals('.old.txt', $methodCall->invokeArgs($bag, ['file.old.txt'])); + $this->assertEquals('.old.txt.jpg', $methodCall->invokeArgs($bag, ['file.old.txt.jpg'])); + $this->assertEquals('.old.txt.jpg', $methodCall->invokeArgs($bag, ['file_something.old.txt.jpg'])); + } } diff --git a/tests/Profiles/BagItProfileBarTest.php b/tests/Profiles/BagItProfileBarTest.php new file mode 100644 index 0000000..24200bd --- /dev/null +++ b/tests/Profiles/BagItProfileBarTest.php @@ -0,0 +1,159 @@ + 'http://canadiana.org/standards/bagit/tdr_ingest.json', + 'spec_version' => '1.2.0', + 'version' => '1.2', + 'source_organization' => 'Candiana.org', + 'external_description' => 'BagIt Profile for ingesting content into the C.O. TDR loading dock.', + 'contact_name' => 'William Wueppelmann', + 'contact_email' => 'tdr@canadiana.com', + 'contact_phone' => null, + 'bag_info_tags' => [ + "Source-Organization" => [ + "required" => true, + "values" => [ + "Simon Fraser University", + "York University" + ], + 'repeatable' => true, + 'description' => '', + ], + "Organization-Address" => [ + "required" => true, + "values" => [ + "8888 University Drive Burnaby, B.C. V5A 1S6 Canada", + "4700 Keele Street Toronto, Ontario M3J 1P3 Canada" + ], + 'repeatable' => true, + 'description' => '', + ], + "Contact-Name" => [ + "required" => true, + "values" => [ + "Mark Jordan", + "Nick Ruest" + ], + 'repeatable' => true, + 'description' => '', + ], + "Contact-Phone" => [ + "required" => false, + 'values' => [], + 'repeatable' => true, + 'description' => '', + ], + "Contact-Email" => [ + "required" => true, + 'values' => [], + 'repeatable' => true, + 'description' => '', + ], + "External-Description" => [ + "required" => true, + 'values' => [], + 'repeatable' => true, + 'description' => '', + ], + "External-Identifier" => [ + "required" => false, + 'values' => [], + 'repeatable' => true, + 'description' => '', + ], + "Bag-Size" => [ + "required" => true, + 'values' => [], + 'repeatable' => true, + 'description' => '', + ], + "Bag-Group-Identifier" => [ + "required" => false, + 'values' => [], + 'repeatable' => true, + 'description' => '', + ], + "Bag-Count" => [ + "required" => true, + 'values' => [], + 'repeatable' => true, + 'description' => '', + ], + "Internal-Sender-Identifier" => [ + "required" => false, + 'values' => [], + 'repeatable' => true, + 'description' => '', + ], + "Internal-Sender-Description" => [ + "required" => false, + 'values' => [], + 'repeatable' => true, + 'description' => '', + ], + "Bagging-Date" => [ + "required" => true, + 'values' => [], + 'repeatable' => true, + 'description' => '', + ], + "Payload-Oxum" => [ + "required" => true, + 'values' => [], + 'repeatable' => true, + 'description' => '', + ], + ], + 'manifests_required' => [ + "md5", + ], + 'manifests_allowed' => [], + 'allow_fetch_txt' => false, + 'require_fetch_txt' => false, + 'data_empty' => false, + 'serialization' => 'optional', + 'accept_serialization' => [ + "application/zip", + ], + 'accept_bagit_version' => [ + "0.96", + ], + 'tag_manifests_required' => [ + "md5" + ], + 'tag_manifests_allowed' => [], + 'tag_files_required' => [ + "DPN/dpnFirstNode.txt", + "DPN/dpnRegistry", + ], + 'tag_files_allowed' => [], + 'payload_files_required' => [], + 'payload_files_allowed' => [], + ]; + } +} diff --git a/tests/Profiles/BagItProfileFooTest.php b/tests/Profiles/BagItProfileFooTest.php new file mode 100644 index 0000000..8ff5bae --- /dev/null +++ b/tests/Profiles/BagItProfileFooTest.php @@ -0,0 +1,82 @@ + 'http://www.library.yale.edu/mssa/bagitprofiles/disk_images.json', + 'spec_version' => '1.1.0', + 'version' => '0.3', + 'source_organization' => 'Yale University', + 'external_description' => 'BagIt Profile for packaging disk images', + 'contact_name' => 'Mark Matienzo', + 'contact_email' => null, + 'contact_phone' => null, + 'bag_info_tags' => [ + 'Bagging-Date' => [ + 'required' => true, + 'values' => [], + 'repeatable' => true, + 'description' => '', + ], + 'Source-Organization' => [ + 'required' => true, + 'values' => [ + 'Simon Fraser University', + 'York University', + ], + 'repeatable' => true, + 'description' => '', + ], + "Contact-Phone" => [ + "required" => true, + "values" => [], + 'repeatable' => true, + 'description' => '', + ], + ], + 'manifests_required' => [ + "md5", + ], + 'manifests_allowed' => [], + 'allow_fetch_txt' => false, + 'require_fetch_txt' => false, + 'data_empty' => false, + 'serialization' => 'required', + 'accept_serialization' => [ + "application/tar", + "application/zip", + ], + 'accept_bagit_version' => [ + "0.96", + "0.97", + ], + 'tag_manifests_required' => [], + 'tag_manifests_allowed' => [], + 'tag_files_required' => [], + 'tag_files_allowed' => [], + 'payload_files_required' => [], + 'payload_files_allowed' => [], + ]; + } +} diff --git a/tests/Profiles/BagProfileTest.php b/tests/Profiles/BagProfileTest.php new file mode 100644 index 0000000..10a091a --- /dev/null +++ b/tests/Profiles/BagProfileTest.php @@ -0,0 +1,32 @@ +assertTrue($profile->isValid()); + $this->tmpdir = $this->prepareExtendedTestBag(); + $bag = Bag::load($this->tmpdir); + $this->assertTrue($bag->isValid()); + $this->expectException(ProfileException::class); + $profile->validateBag($bag); + } +} diff --git a/tests/Profiles/BasicProfileTest.php b/tests/Profiles/BasicProfileTest.php deleted file mode 100644 index 5eb4a82..0000000 --- a/tests/Profiles/BasicProfileTest.php +++ /dev/null @@ -1,325 +0,0 @@ - [ - 'filename' => self::PROFILE_DIR . '/bagProfileFoo.json', - 'headers' => [ - 'Content-Type' => 'application/json', - ], - ], - 'profile_bar.json' => [ - 'filename' => self::PROFILE_DIR . '/bagProfileBar.json', - 'headers' => [ - 'Content-Type' => 'application/json', - ], - ], - ]; - parent::setUpBeforeClass(); - } - - /** - * Test the first example profile from the specification. - * @throws ProfileException - * @covers \whikloj\BagItTools\Profiles\BagItProfile::fromJson - */ - public function testSpecProfileFoo(): void - { - $json = file_get_contents(self::TEST_RESOURCES . '/profiles/bagProfileFoo.json'); - $profile = BagItProfile::fromJson($json); - $this->assertTrue($profile->isValid()); - $this->assertExampleProfileFoo($profile); - } - - /** - * Test the second example profile from the specification. - * @throws ProfileException - * @covers \whikloj\BagItTools\Profiles\BagItProfile::fromJson - */ - public function testSpecProfileBar(): void - { - $json = file_get_contents(self::TEST_RESOURCES . '/profiles/bagProfileBar.json'); - $profile = BagItProfile::fromJson($json); - $this->assertTrue($profile->isValid()); - $this->assertExampleProfileBar($profile); - } - - /** - * Test the first example profile retrieved from webserver. - * @throws ProfileException - * @covers \whikloj\BagItTools\Profiles\ProfileFactory::generateProfileFromUri - */ - public function testFactoryFoo(): void - { - $profile = ProfileFactory::generateProfileFromUri(self::$remote_urls[0]); - $this->assertTrue($profile->isValid()); - $this->assertExampleProfileFoo($profile); - } - - /** - * Test the second example profile retrieved from webserver. - * @throws ProfileException - * @covers \whikloj\BagItTools\Profiles\ProfileFactory::generateProfileFromUri - */ - public function testFactoryBar(): void - { - $profile = ProfileFactory::generateProfileFromUri(self::$remote_urls[1]); - $this->assertTrue($profile->isValid()); - $this->assertExampleProfileBar($profile); - } - - /** - * Validate the BagItProfile specification example bagProfileFoo - * @param BagItProfile $profile The profile to check. - */ - private function assertExampleProfileFoo(BagItProfile $profile): void - { - $this->assertEquals( - 'http://www.library.yale.edu/mssa/bagitprofiles/disk_images.json', - $profile->getProfileIdentifier() - ); - $this->assertEquals('1.1.0', $profile->getProfileSpecVersion()); - $this->assertEquals('0.3', $profile->getVersion()); - $this->assertEquals('Yale University', $profile->getSourceOrganization()); - $this->assertEquals('BagIt Profile for packaging disk images', $profile->getExternalDescription()); - $this->assertEquals('Mark Matienzo', $profile->getContactName()); - $this->assertNull($profile->getContactEmail()); - $this->assertNull($profile->getContactPhone()); - $expected_tags = [ - 'Bagging-Date' => [ - 'required' => true, - 'values' => [], - 'repeatable' => true, - 'description' => '', - ], - 'Source-Organization' => [ - 'required' => true, - 'values' => [ - 'Simon Fraser University', - 'York University', - ], - 'repeatable' => true, - 'description' => '', - ], - ]; - $this->assertProfileBagInfoTags($expected_tags, $profile); - $this->assertArrayEquals(['md5'], $profile->getManifestsRequired()); - $this->assertArrayEquals([], $profile->getManifestsAllowed()); - $this->assertArrayEquals([], $profile->getTagManifestsRequired()); - $this->assertArrayEquals([], $profile->getTagManifestsAllowed()); - $this->assertArrayEquals([], $profile->getTagFilesRequired()); - $this->assertArrayEquals([], $profile->getTagFilesAllowed()); - $this->assertFalse($profile->isAllowFetchTxt()); - $this->assertFalse($profile->isRequireFetchTxt()); - $this->assertFalse($profile->isDataEmpty()); - $this->assertEquals("required", $profile->getSerialization()); - $this->assertArrayEquals( - [ - "application/zip", - "application/tar" - ], - $profile->getAcceptSerialization() - ); - $this->assertArrayEquals( - [ - "0.96", - "0.97", - ], - $profile->getAcceptBagItVersion() - ); - $this->assertArrayEquals([], $profile->getPayloadFilesRequired()); - $this->assertArrayEquals([], $profile->getPayloadFilesAllowed()); - } - - /** - * Validate the BagItProfile specification example bagProfileBar - * @param BagItProfile $profile The profile to check. - */ - private function assertExampleProfileBar(BagItProfile $profile): void - { - $this->assertEquals( - 'http://canadiana.org/standards/bagit/tdr_ingest.json', - $profile->getProfileIdentifier() - ); - $this->assertEquals('1.2.0', $profile->getProfileSpecVersion()); - $this->assertEquals('1.2', $profile->getVersion()); - $this->assertEquals('Candiana.org', $profile->getSourceOrganization()); - $this->assertEquals( - 'BagIt Profile for ingesting content into the C.O. TDR loading dock.', - $profile->getExternalDescription() - ); - $this->assertEquals('William Wueppelmann', $profile->getContactName()); - $this->assertEquals('tdr@canadiana.com', $profile->getContactEmail()); - $this->assertNull($profile->getContactPhone()); - $expected_tags = [ - "Source-Organization" => [ - "required" => true, - "values" => [ - "Simon Fraser University", - "York University" - ], - 'repeatable' => true, - 'description' => '', - ], - "Organization-Address" => [ - "required" => true, - "values" => [ - "8888 University Drive Burnaby, B.C. V5A 1S6 Canada", - "4700 Keele Street Toronto, Ontario M3J 1P3 Canada" - ], - 'repeatable' => true, - 'description' => '', - ], - "Contact-Name" => [ - "required" => true, - "values" => [ - "Mark Jordan", - "Nick Ruest" - ], - 'repeatable' => true, - 'description' => '', - ], - "Contact-Phone" => [ - "required" => false, - 'values' => [], - 'repeatable' => true, - 'description' => '', - ], - "Contact-Email" => [ - "required" => true, - 'values' => [], - 'repeatable' => true, - 'description' => '', - ], - "External-Description" => [ - "required" => true, - 'values' => [], - 'repeatable' => true, - 'description' => '', - ], - "External-Identifier" => [ - "required" => false, - 'values' => [], - 'repeatable' => true, - 'description' => '', - ], - "Bag-Size" => [ - "required" => true, - 'values' => [], - 'repeatable' => true, - 'description' => '', - ], - "Bag-Group-Identifier" => [ - "required" => false, - 'values' => [], - 'repeatable' => true, - 'description' => '', - ], - "Bag-Count" => [ - "required" => true, - 'values' => [], - 'repeatable' => true, - 'description' => '', - ], - "Internal-Sender-Identifier" => [ - "required" => false, - 'values' => [], - 'repeatable' => true, - 'description' => '', - ], - "Internal-Sender-Description" => [ - "required" => false, - 'values' => [], - 'repeatable' => true, - 'description' => '', - ], - "Bagging-Date" => [ - "required" => true, - 'values' => [], - 'repeatable' => true, - 'description' => '', - ], - "Payload-Oxum" => [ - "required" => true, - 'values' => [], - 'repeatable' => true, - 'description' => '', - ], - ]; - $this->assertProfileBagInfoTags($expected_tags, $profile); - $this->assertArrayEquals(['md5'], $profile->getManifestsRequired()); - $this->assertArrayEquals([], $profile->getManifestsAllowed()); - $this->assertArrayEquals(['md5'], $profile->getTagManifestsRequired()); - $this->assertArrayEquals([], $profile->getTagManifestsAllowed()); - $this->assertArrayEquals( - [ - "DPN/dpnFirstNode.txt", - "DPN/dpnRegistry" - ], - $profile->getTagFilesRequired() - ); - $this->assertArrayEquals([], $profile->getTagFilesAllowed()); - $this->assertFalse($profile->isAllowFetchTxt()); - $this->assertFalse($profile->isRequireFetchTxt()); - $this->assertFalse($profile->isDataEmpty()); - $this->assertEquals("optional", $profile->getSerialization()); - $this->assertArrayEquals( - [ - "application/zip", - ], - $profile->getAcceptSerialization() - ); - $this->assertArrayEquals( - [ - "0.96", - ], - $profile->getAcceptBagItVersion() - ); - $this->assertArrayEquals([], $profile->getPayloadFilesRequired()); - $this->assertArrayEquals([], $profile->getPayloadFilesAllowed()); - } - - public function testValidateBag1(): void - { - $profile = ProfileFactory::generateProfileFromUri(self::$remote_urls[0]); - $this->assertTrue($profile->isValid()); - $this->tmpdir = $this->prepareExtendedTestBag(); - $bag = Bag::load($this->tmpdir); - $this->assertTrue($bag->isValid()); - $this->expectException(ProfileException::class); - $profile->validateBag($bag); - } - - /** - * Assert the bag-info tags are as expected. - * @param array $expected The expected tags. - * @param BagItProfile $profile The profile to check. - */ - private function assertProfileBagInfoTags(array $expected, BagItProfile $profile): void - { - foreach ($expected as $tag => $value) { - $this->assertArrayHasKey(strtolower($tag), $profile->getBagInfoTags()); - $profileTag = $profile->getBagInfoTags()[strtolower($tag)]; - $this->assertEquals($value['required'], $profileTag->isRequired()); - $this->assertEquals($value['repeatable'], $profileTag->isRepeatable()); - $this->assertArrayEquals($value['values'], $profileTag->getValues()); - $this->assertEquals($value['description'], $profileTag->getDescription()); - } - } -} diff --git a/tests/Profiles/ProfileTestFramework.php b/tests/Profiles/ProfileTestFramework.php new file mode 100644 index 0000000..363b57e --- /dev/null +++ b/tests/Profiles/ProfileTestFramework.php @@ -0,0 +1,495 @@ + [ + 'filename' => self::PROFILE_DIR . '/bagProfileFoo.json', + 'headers' => [ + 'Content-Type' => 'application/json', + ], + ], + 'profile_bar.json' => [ + 'filename' => self::PROFILE_DIR . '/bagProfileBar.json', + 'headers' => [ + 'Content-Type' => 'application/json', + ], + ], + 'profile_btr.json' => [ + 'filename' => self::PROFILE_DIR . '/btrProfile.json', + 'headers' => [ + 'Content-Type' => 'application/json', + ], + ] + ]; + parent::setUpBeforeClass(); + } + + /** + * @var BagItProfile The profile loaded from a URI. + */ + protected BagItProfile $uriProfile; + + /** + * @var BagItProfile The profile loaded from a JSON file. + */ + protected BagItProfile $jsonProfile; + + /** + * @var array An array of the test values to match against the profile. + */ + protected array $profileValues = []; + + public function setUp(): void + { + parent::setUp(); + + $json = file_get_contents($this->getProfileFilename()); + $this->jsonProfile = BagItProfile::fromJson($json); + $this->assertTrue($this->jsonProfile->isValid()); + + $this->uriProfile = ProfileFactory::generateProfileFromUri($this->getProfileUri()); + $this->assertTrue($this->uriProfile->isValid()); + + $this->profileValues = $this->getProfileValues(); + } + + /** + * @return string The path to the profile JSON file. + */ + abstract protected function getProfileFilename(): string; + + /** + * @return string The URI to the profile. + */ + abstract protected function getProfileUri(): string; + + /** + * @return array The expected values of the profile. + */ + abstract protected function getProfileValues(): array; + + /** + * @group Profiles + * @covers ::fromJson + * @covers \whikloj\BagItTools\Profiles\ProfileFactory::generateProfileFromUri + * @covers ::isValid + * @covers ::setProfileIdentifier + * @covers ::getProfileIdentifier + */ + public function testProfileIdentifier(): void + { + $this->assertEquals( + $this->profileValues['identifier'], + $this->jsonProfile->getProfileIdentifier() + ); + $this->assertEquals( + $this->profileValues['identifier'], + $this->uriProfile->getProfileIdentifier() + ); + } + + /** + * @group Profiles + * @covers ::setProfileSpecVersion + * @covers ::getProfileSpecVersion + */ + public function testProfileSpecVersion(): void + { + $this->assertEquals( + $this->profileValues['spec_version'], + $this->jsonProfile->getProfileSpecVersion() + ); + $this->assertEquals( + $this->profileValues['spec_version'], + $this->uriProfile->getProfileSpecVersion() + ); + } + + /** + * @group Profiles + * @covers ::setVersion + * @covers ::getVersion + */ + public function testProfileVersion(): void + { + $this->assertEquals( + $this->profileValues['version'], + $this->jsonProfile->getVersion() + ); + $this->assertEquals( + $this->profileValues['version'], + $this->uriProfile->getVersion() + ); + } + + /** + * @group Profiles + * @covers ::setSourceOrganization + * @covers ::getSourceOrganization + */ + public function testGetSourceOrganization(): void + { + $this->assertEquals( + $this->profileValues['source_organization'], + $this->jsonProfile->getSourceOrganization() + ); + $this->assertEquals( + $this->profileValues['source_organization'], + $this->uriProfile->getSourceOrganization() + ); + } + + /** + * @group Profiles + * @covers ::setExternalDescription + * @covers ::getExternalDescription + */ + public function testGetExternalDescription(): void + { + $this->assertEquals( + $this->profileValues['external_description'], + $this->jsonProfile->getExternalDescription() + ); + $this->assertEquals( + $this->profileValues['external_description'], + $this->uriProfile->getExternalDescription() + ); + } + + /** + * @group Profiles + * @covers ::setContactName + * @covers ::getContactName + */ + public function testGetContactName(): void + { + $this->assertEquals( + $this->profileValues['contact_name'], + $this->jsonProfile->getContactName() + ); + $this->assertEquals( + $this->profileValues['contact_name'], + $this->uriProfile->getContactName() + ); + } + + /** + * @group Profiles + * @covers ::setContactEmail + * @covers ::getContactEmail + */ + public function testGetContactEmail(): void + { + $this->assertEquals( + $this->profileValues['contact_email'], + $this->jsonProfile->getContactEmail() + ); + $this->assertEquals( + $this->profileValues['contact_email'], + $this->uriProfile->getContactEmail() + ); + } + + /** + * @group Profiles + * @covers ::setContactPhone + * @covers ::getContactPhone + */ + public function testGetContactPhone(): void + { + $this->assertEquals( + $this->profileValues['contact_phone'], + $this->jsonProfile->getContactPhone() + ); + $this->assertEquals( + $this->profileValues['contact_phone'], + $this->uriProfile->getContactPhone() + ); + } + + /** + * @group Profiles + * @covers ::setBagInfoTags + * @covers ::getBagInfoTags + */ + public function testGetBagInfoTags(): void + { + $this->assertProfileBagInfoTags($this->profileValues['bag_info_tags'], $this->jsonProfile); + $this->assertProfileBagInfoTags($this->profileValues['bag_info_tags'], $this->uriProfile); + } + + /** + * @group Profiles + * @covers ::setManifestsRequired + * @covers ::getManifestsRequired + */ + public function testManifestRequired(): void + { + $this->assertArrayEquals( + $this->profileValues['manifests_required'], + $this->jsonProfile->getManifestsRequired() + ); + $this->assertArrayEquals( + $this->profileValues['manifests_required'], + $this->uriProfile->getManifestsRequired() + ); + } + + + /** + * @group Profiles + * @covers ::setManifestsAllowed + * @covers ::getManifestsAllowed + */ + public function testManifestAllowed(): void + { + $this->assertArrayEquals( + $this->profileValues['manifests_allowed'], + $this->jsonProfile->getManifestsAllowed() + ); + $this->assertArrayEquals( + $this->profileValues['manifests_allowed'], + $this->uriProfile->getManifestsAllowed() + ); + } + + /** + * @group Profiles + * @covers ::setAllowFetchTxt + * @covers ::isAllowFetchTxt + */ + public function testAllowFetchTxt(): void + { + $this->assertEquals( + $this->profileValues['allow_fetch_txt'], + $this->jsonProfile->isAllowFetchTxt() + ); + $this->assertEquals( + $this->profileValues['allow_fetch_txt'], + $this->uriProfile->isAllowFetchTxt() + ); + } + + /** + * @group Profiles + * @covers ::setRequireFetchTxt + * @covers ::isRequireFetchTxt + */ + public function testRequireFetchTxt(): void + { + $this->assertEquals( + $this->profileValues['require_fetch_txt'], + $this->jsonProfile->isRequireFetchTxt() + ); + $this->assertEquals( + $this->profileValues['require_fetch_txt'], + $this->uriProfile->isRequireFetchTxt() + ); + } + + /** + * @group Profiles + * @covers ::setDataEmpty + * @covers ::isDataEmpty + */ + public function testDataEmpty(): void + { + $this->assertEquals( + $this->profileValues['data_empty'], + $this->jsonProfile->isDataEmpty() + ); + $this->assertEquals( + $this->profileValues['data_empty'], + $this->uriProfile->isDataEmpty() + ); + } + + /** + * @group Profiles + * @covers ::setSerialization + * @covers ::getSerialization + */ + public function testSerialization(): void + { + $this->assertEquals( + $this->profileValues['serialization'], + $this->jsonProfile->getSerialization() + ); + $this->assertEquals( + $this->profileValues['serialization'], + $this->uriProfile->getSerialization() + ); + } + + /** + * @group Profiles + * @covers ::setAcceptSerialization + * @covers ::getAcceptSerialization + */ + public function testAcceptSerialization(): void + { + $this->assertArrayEquals( + $this->profileValues['accept_serialization'], + $this->jsonProfile->getAcceptSerialization() + ); + $this->assertArrayEquals( + $this->profileValues['accept_serialization'], + $this->uriProfile->getAcceptSerialization() + ); + } + + /** + * @group Profiles + * @covers ::setAcceptBagItVersion + * @covers ::getAcceptBagItVersion + */ + public function testAcceptBagItVersion(): void + { + $this->assertArrayEquals( + $this->profileValues['accept_bagit_version'], + $this->jsonProfile->getAcceptBagItVersion() + ); + $this->assertArrayEquals( + $this->profileValues['accept_bagit_version'], + $this->uriProfile->getAcceptBagItVersion() + ); + } + + /** + * @group Profiles + * @covers ::setTagManifestsRequired + * @covers ::getTagManifestsRequired + */ + public function testTagManifestsRequired(): void + { + $this->assertArrayEquals( + $this->profileValues['tag_manifests_required'], + $this->jsonProfile->getTagManifestsRequired() + ); + $this->assertArrayEquals( + $this->profileValues['tag_manifests_required'], + $this->uriProfile->getTagManifestsRequired() + ); + } + + /** + * @group Profiles + * @covers ::setTagManifestsAllowed + * @covers ::getTagManifestsAllowed + */ + public function testTagManifestsAllowed(): void + { + $this->assertArrayEquals( + $this->profileValues['tag_manifests_allowed'], + $this->jsonProfile->getTagManifestsAllowed() + ); + $this->assertArrayEquals( + $this->profileValues['tag_manifests_allowed'], + $this->uriProfile->getTagManifestsAllowed() + ); + } + + /** + * @group Profiles + * @covers ::setTagFilesRequired + * @covers ::getTagFilesRequired + */ + public function testTagFilesRequired(): void + { + $this->assertArrayEquals( + $this->profileValues['tag_files_required'], + $this->jsonProfile->getTagFilesRequired() + ); + $this->assertArrayEquals( + $this->profileValues['tag_files_required'], + $this->uriProfile->getTagFilesRequired() + ); + } + + /** + * @group Profiles + * @covers ::setTagFilesAllowed + * @covers ::getTagFilesAllowed + */ + public function testTagFilesAllowed(): void + { + $this->assertArrayEquals( + $this->profileValues['tag_files_allowed'], + $this->jsonProfile->getTagFilesAllowed() + ); + $this->assertArrayEquals( + $this->profileValues['tag_files_allowed'], + $this->uriProfile->getTagFilesAllowed() + ); + } + + /** + * @group Profiles + * @covers ::setPayloadFilesRequired + * @covers ::getPayloadFilesRequired + */ + public function testPayloadFilesRequired(): void + { + $this->assertArrayEquals( + $this->profileValues['payload_files_required'], + $this->jsonProfile->getPayloadFilesRequired() + ); + $this->assertArrayEquals( + $this->profileValues['payload_files_required'], + $this->uriProfile->getPayloadFilesRequired() + ); + } + + /** + * @group Profiles + * @covers ::setPayloadFilesAllowed + * @covers ::getPayloadFilesAllowed + */ + public function testPayloadFilesAllowed(): void + { + $this->assertArrayEquals( + $this->profileValues['payload_files_allowed'], + $this->jsonProfile->getPayloadFilesAllowed() + ); + $this->assertArrayEquals( + $this->profileValues['payload_files_allowed'], + $this->uriProfile->getPayloadFilesAllowed() + ); + } + + + + /** + * Assert the bag-info tags are as expected. + * @param array $expected The expected tags. + * @param BagItProfile $profile The profile to check. + */ + protected function assertProfileBagInfoTags(array $expected, BagItProfile $profile): void + { + foreach ($expected as $tag => $value) { + $this->assertArrayHasKey(strtolower($tag), $profile->getBagInfoTags()); + $profileTag = $profile->getBagInfoTags()[strtolower($tag)]; + $this->assertEquals($value['required'], $profileTag->isRequired()); + $this->assertEquals($value['repeatable'], $profileTag->isRepeatable()); + $this->assertArrayEquals($value['values'], $profileTag->getValues()); + $this->assertEquals($value['description'], $profileTag->getDescription()); + } + } +} From 9346545c0d9e7fc3d286f4a2290012e7a5414417 Mon Sep 17 00:00:00 2001 From: Jared Whiklo Date: Fri, 12 Apr 2024 16:20:50 -0500 Subject: [PATCH 10/21] Fixes and more profile tests --- src/Profiles/BagItProfile.php | 29 ++-- tests/Profiles/BagItProfileBarTest.php | 2 +- tests/Profiles/BagItProfileFooTest.php | 2 +- tests/Profiles/BagProfileTest.php | 154 +++++++++++++++++- .../test_profiles/allow_fetch_invalid.json | 16 ++ .../test_profiles/allow_fetch_invalid_2.json | 16 ++ .../profiles/test_profiles/data_empty.json | 19 +++ .../invalid_bag_info_tag_options.json | 24 +++ .../profile_identifier_bag_info_tag.json | 19 +++ .../set_manifest_allowed_invalid.json | 18 ++ .../set_manifest_allowed_valid.json | 18 ++ 11 files changed, 302 insertions(+), 15 deletions(-) create mode 100644 tests/resources/profiles/test_profiles/allow_fetch_invalid.json create mode 100644 tests/resources/profiles/test_profiles/allow_fetch_invalid_2.json create mode 100644 tests/resources/profiles/test_profiles/data_empty.json create mode 100644 tests/resources/profiles/test_profiles/invalid_bag_info_tag_options.json create mode 100644 tests/resources/profiles/test_profiles/profile_identifier_bag_info_tag.json create mode 100644 tests/resources/profiles/test_profiles/set_manifest_allowed_invalid.json create mode 100644 tests/resources/profiles/test_profiles/set_manifest_allowed_valid.json diff --git a/src/Profiles/BagItProfile.php b/src/Profiles/BagItProfile.php index c0645c9..f8860a7 100644 --- a/src/Profiles/BagItProfile.php +++ b/src/Profiles/BagItProfile.php @@ -347,12 +347,9 @@ private function setBagInfoTags(array $bagInfoTags): BagItProfile if (count(array_diff_key($tagOpts, $expectedKeys)) > 0) { throw new ProfileException("Invalid tag options for $tagName"); } - if (array_key_exists($tagName, $this->profileBagInfoTags)) { - throw new ProfileException("Duplicate tag $tagName"); - } if (self::matchStrings('BagIt-Profile-Identifier', $tagName)) { - $this->profileWarnings[] = "The tag BagIt-Profile-Identifier is always required, but SHOULD NOT be - listed under Bag-Info in the Profile."; + $this->profileWarnings[] = "The tag BagIt-Profile-Identifier is always required, but SHOULD NOT be " . + "listed under Bag-Info in the Profile."; } else { $profileTag = ProfileTags::fromJson($tagName, $tagOpts); $this->profileBagInfoTags[BagUtils::trimLower($tagName)] = $profileTag; @@ -442,7 +439,7 @@ public function isRequireFetchTxt(): bool private function setRequireFetchTxt(?bool $requireFetchTxt): BagItProfile { if ($requireFetchTxt === true && $this->allowFetchTxt === false) { - throw new ProfileException("Require-Fetch.txt cannot be true if Allow-Fetch.txt is false"); + throw new ProfileException("Allow-Fetch.txt cannot be false if Require-Fetch.txt is true"); } $this->requireFetchTxt = $requireFetchTxt ?? false; return $this; @@ -887,16 +884,17 @@ public function validateBag(Bag $bag): bool $errors[] = "Profile requires fetch.txt but the bag does not have one"; } if ($this->isDataEmpty()) { - $manifests = $bag->getPayloadManifests()[0]; + $manifests = current($bag->getPayloadManifests()); $hashes = $manifests->getHashes(); if (count($hashes) > 1) { $errors[] = "Profile requires /data directory to be empty or contain a single 0 byte file but it" . - "contains " . count($hashes) . " files"; + " contains " . count($hashes) . " files"; } elseif (count($hashes) == 1) { - $file = reset($hashes); - if (stat($file)['size'] > 0) { + $file = array_key_first($hashes); + $absolute = $bag->makeAbsolute($file); + if (stat($absolute)['size'] > 0) { $errors[] = "Profile requires /data directory to be empty or contain a single 0 byte file but it" . - "contains a single file of size " . stat($file)['size']; + " contains a single file of size " . stat($absolute)['size']; } } } @@ -1008,4 +1006,13 @@ public function validateBag(Bag $bag): bool } return true; } + + /** + * Get the list of warnings generated during the validation of the profile. + * @return array The list of warnings. + */ + public function getWarnings(): array + { + return $this->profileWarnings; + } } diff --git a/tests/Profiles/BagItProfileBarTest.php b/tests/Profiles/BagItProfileBarTest.php index 24200bd..47f538d 100644 --- a/tests/Profiles/BagItProfileBarTest.php +++ b/tests/Profiles/BagItProfileBarTest.php @@ -1,6 +1,6 @@ assertTrue($profile->isValid()); $this->tmpdir = $this->prepareExtendedTestBag(); $bag = Bag::load($this->tmpdir); @@ -29,4 +31,152 @@ public function testValidateBag1(): void $this->expectException(ProfileException::class); $profile->validateBag($bag); } + + /** + * Try to load a bad profile. + * @group Profiles + * @covers ::setBagInfoTags + */ + public function testInvalidBagInfoTags(): void + { + $this->expectException(ProfileException::class); + $this->expectExceptionMessage("Invalid tag options for Source-Organization"); + BagItProfile::fromJson(file_get_contents(self::$profiles . "/test_profiles/invalid_bag_info_tag_options.json")); + } + + /** + * Try to load a profile with the BagIt-Profile-Identifier specified in the Bag-Info tags. + * @group Profiles + * @covers ::setBagInfoTags + */ + public function testBagItProfileIdentifierInTags(): void + { + $profile = BagItProfile::fromJson(file_get_contents( + self::$profiles . "/test_profiles/profile_identifier_bag_info_tag.json" + )); + $this->assertArrayEquals( + [ + "The tag BagIt-Profile-Identifier is always required, but SHOULD NOT be listed under Bag-Info in " . + "the Profile." + ], + $profile->getWarnings() + ); + } + + /** + * @group Profiles + * @covers ::setManifestsAllowed + */ + public function testSetManifestAllowed(): void + { + $profile = BagItProfile::fromJson(file_get_contents( + self::$profiles . "/test_profiles/set_manifest_allowed_valid.json" + )); + $this->assertTrue($profile->isValid()); + } + + /** + * @group Profiles + * @covers ::setManifestsAllowed + */ + public function testSetManifestAllowedInvalid(): void + { + $this->expectException(ProfileException::class); + $this->expectExceptionMessage("Manifests-Allowed must include all entries from Manifests-Required"); + BagItProfile::fromJson(file_get_contents( + self::$profiles . "/test_profiles/set_manifest_allowed_invalid.json" + )); + } + + /** + * @group Profiles + * @covers ::setAllowFetchTxt + */ + public function testAllowFetchInvalid(): void + { + $this->expectException(ProfileException::class); + $this->expectExceptionMessage("Allow-Fetch.txt cannot be false if Require-Fetch.txt is true"); + BagItProfile::fromJson(file_get_contents( + self::$profiles . "/test_profiles/allow_fetch_invalid.json" + )); + } + + /** + * @group Profiles + * @covers ::setRequireFetchTxt + */ + public function testAllowFetchInvalid2(): void + { + $this->expectException(ProfileException::class); + $this->expectExceptionMessage("Allow-Fetch.txt cannot be false if Require-Fetch.txt is true"); + BagItProfile::fromJson(file_get_contents( + self::$profiles . "/test_profiles/allow_fetch_invalid_2.json" + )); + } + + /** + * @group Profiles + * @covers ::setDataEmpty + * @covers ::validateBag + */ + public function testDataEmpty(): void + { + $profile = BagItProfile::fromJson(file_get_contents( + self::$profiles . "/test_profiles/data_empty.json" + )); + $this->assertTrue($profile->isValid()); + $bag = Bag::create($this->tmpdir); + $bag->update(); + // Empty data is valid. + $this->assertTrue($profile->validateBag($bag)); + + $bag->addFile(self::TEST_RESOURCES . "/text/empty.txt", "empty.txt"); + $bag->update(); + // A single zero byte file is valid. + $this->assertTrue($profile->validateBag($bag)); + + $bag->addFile(self::TEST_RESOURCES . "/text/empty.txt", "empty2.txt"); + $bag->update(); + // Two zero byte files is not valid. + $this->expectException(ProfileException::class); + $this->expectExceptionMessage("Profile requires /data directory to be empty or contain a single 0 " . + "byte file but it contains 2 files"); + $profile->validateBag($bag); + + $bag->removeFile("empty2.txt"); + $bag->removeFile("empty.txt"); + $bag->update(); + // Empty data directory is valid. + $this->assertTrue($profile->validateBag($bag)); + + $bag->addFile(self::TEST_RESOURCES . "/images/scenic-landscape.jpg", "scenic-landscape.jpg"); + $bag->update(); + // A single non-zero byte file is not valid. + $this->assertFalse($profile->validateBag($bag)); + } + + /** + * @group Profiles + * @covers ::setDataEmpty + * @covers ::validateBag + */ + public function testDataEmpty2(): void + { + $profile = BagItProfile::fromJson(file_get_contents( + self::$profiles . "/test_profiles/data_empty.json" + )); + $this->assertTrue($profile->isValid()); + $bag = Bag::create($this->tmpdir); + $bag->update(); + // Empty data is valid. + $this->assertTrue($profile->validateBag($bag)); + + $bag->addFile(self::TEST_RESOURCES . "/images/scenic-landscape.jpg", "scenic-landscape.jpg"); + $bag->update(); + // A single non-zero byte file is not valid. + $this->expectException(ProfileException::class); + $this->expectExceptionMessage("Profile requires /data directory to be empty or contain a single 0 byte " . + "file but it contains a single file of size 398246"); + $profile->validateBag($bag); + } } diff --git a/tests/resources/profiles/test_profiles/allow_fetch_invalid.json b/tests/resources/profiles/test_profiles/allow_fetch_invalid.json new file mode 100644 index 0000000..3f86f0b --- /dev/null +++ b/tests/resources/profiles/test_profiles/allow_fetch_invalid.json @@ -0,0 +1,16 @@ +{ + "BagIt-Profile-Info":{ + "BagIt-Profile-Identifier":"http://somewhere.org/my/profile.json", + "BagIt-Profile-Version": "0.1", + "Source-Organization":"Monsters, Inc.", + "Contact-Name":"Mike Wazowski", + "External-Description":"Profile for testing bad bag info tag options", + "Version":"0.3" + }, + "Require-Fetch.txt": true, + "Allow-Fetch.txt": false, + "Serialization": "forbidden", + "Accept-BagIt-Version":[ + "1.0" + ] +} \ No newline at end of file diff --git a/tests/resources/profiles/test_profiles/allow_fetch_invalid_2.json b/tests/resources/profiles/test_profiles/allow_fetch_invalid_2.json new file mode 100644 index 0000000..cdfeef5 --- /dev/null +++ b/tests/resources/profiles/test_profiles/allow_fetch_invalid_2.json @@ -0,0 +1,16 @@ +{ + "BagIt-Profile-Info":{ + "BagIt-Profile-Identifier":"http://somewhere.org/my/profile.json", + "BagIt-Profile-Version": "0.1", + "Source-Organization":"Monsters, Inc.", + "Contact-Name":"Mike Wazowski", + "External-Description":"Profile for testing bad bag info tag options", + "Version":"0.3" + }, + "Allow-Fetch.txt": false, + "Require-Fetch.txt": true, + "Serialization": "forbidden", + "Accept-BagIt-Version":[ + "1.0" + ] +} \ No newline at end of file diff --git a/tests/resources/profiles/test_profiles/data_empty.json b/tests/resources/profiles/test_profiles/data_empty.json new file mode 100644 index 0000000..968af9f --- /dev/null +++ b/tests/resources/profiles/test_profiles/data_empty.json @@ -0,0 +1,19 @@ +{ + "BagIt-Profile-Info":{ + "BagIt-Profile-Identifier":"http://somewhere.org/my/profile.json", + "BagIt-Profile-Version": "0.1", + "Source-Organization":"Monsters, Inc.", + "Contact-Name":"Mike Wazowski", + "External-Description":"Profile for testing bad bag info tag options", + "Version":"0.3" + }, + "Manifests-Allowed": [ + "md5", + "sha512" + ], + "Data-Empty": true, + "Serialization": "forbidden", + "Accept-BagIt-Version":[ + "1.0" + ] +} \ No newline at end of file diff --git a/tests/resources/profiles/test_profiles/invalid_bag_info_tag_options.json b/tests/resources/profiles/test_profiles/invalid_bag_info_tag_options.json new file mode 100644 index 0000000..3152d5e --- /dev/null +++ b/tests/resources/profiles/test_profiles/invalid_bag_info_tag_options.json @@ -0,0 +1,24 @@ +{ + "BagIt-Profile-Info":{ + "BagIt-Profile-Identifier":"http://somewhere.org/my/profile.json", + "BagIt-Profile-Version": "0.1", + "Source-Organization":"Monsters, Inc.", + "Contact-Name":"Mike Wazowski", + "External-Description":"Profile for testing bad bag info tag options", + "Version":"0.3" + }, + "Bag-Info":{ + "Source-Organization":{ + "required":true, + "values":[ + "Simon Fraser University", + "York University" + ], + "help": "This is the organization that originally created the bag." + } + }, + "Serialization": "forbidden", + "Accept-BagIt-Version":[ + "1.0" + ] +} \ No newline at end of file diff --git a/tests/resources/profiles/test_profiles/profile_identifier_bag_info_tag.json b/tests/resources/profiles/test_profiles/profile_identifier_bag_info_tag.json new file mode 100644 index 0000000..d7a2944 --- /dev/null +++ b/tests/resources/profiles/test_profiles/profile_identifier_bag_info_tag.json @@ -0,0 +1,19 @@ +{ + "BagIt-Profile-Info":{ + "BagIt-Profile-Identifier":"http://somewhere.org/my/profile.json", + "BagIt-Profile-Version": "0.1", + "Source-Organization":"Monsters, Inc.", + "Contact-Name":"Mike Wazowski", + "External-Description":"Profile for testing bad bag info tag options", + "Version":"0.3" + }, + "Bag-Info":{ + "BagIt-Profile-Identifier":{ + "required":true + } + }, + "Serialization": "forbidden", + "Accept-BagIt-Version":[ + "1.0" + ] +} \ No newline at end of file diff --git a/tests/resources/profiles/test_profiles/set_manifest_allowed_invalid.json b/tests/resources/profiles/test_profiles/set_manifest_allowed_invalid.json new file mode 100644 index 0000000..0b20c8b --- /dev/null +++ b/tests/resources/profiles/test_profiles/set_manifest_allowed_invalid.json @@ -0,0 +1,18 @@ +{ + "BagIt-Profile-Info":{ + "BagIt-Profile-Identifier":"http://somewhere.org/my/profile.json", + "BagIt-Profile-Version": "0.1", + "Source-Organization":"Monsters, Inc.", + "Contact-Name":"Mike Wazowski", + "External-Description":"Profile for testing bad bag info tag options", + "Version":"0.3" + }, + "Manifests-Required": [ + "md5", + "sha512" + ], + "Manifests-Allowed": [ + "md5", + "sha256" + ] +} \ No newline at end of file diff --git a/tests/resources/profiles/test_profiles/set_manifest_allowed_valid.json b/tests/resources/profiles/test_profiles/set_manifest_allowed_valid.json new file mode 100644 index 0000000..b0840c3 --- /dev/null +++ b/tests/resources/profiles/test_profiles/set_manifest_allowed_valid.json @@ -0,0 +1,18 @@ +{ + "BagIt-Profile-Info":{ + "BagIt-Profile-Identifier":"http://somewhere.org/my/profile.json", + "BagIt-Profile-Version": "0.1", + "Source-Organization":"Monsters, Inc.", + "Contact-Name":"Mike Wazowski", + "External-Description":"Profile for testing bad bag info tag options", + "Version":"0.3" + }, + "Manifests-Allowed": [ + "md5", + "sha256" + ], + "Serialization": "forbidden", + "Accept-BagIt-Version":[ + "1.0" + ] +} \ No newline at end of file From eae87ef74309ad9ceb6e7126a29ef8040de6e531 Mon Sep 17 00:00:00 2001 From: Jared Whiklo Date: Sat, 13 Apr 2024 12:37:45 -0500 Subject: [PATCH 11/21] Always check for Allow-Fetch before Require-Fetch, so the reverse test is unnecessary --- src/Profiles/BagItProfile.php | 4 ---- tests/Profiles/BagProfileTest.php | 13 ------------- .../test_profiles/allow_fetch_invalid_2.json | 16 ---------------- 3 files changed, 33 deletions(-) delete mode 100644 tests/resources/profiles/test_profiles/allow_fetch_invalid_2.json diff --git a/src/Profiles/BagItProfile.php b/src/Profiles/BagItProfile.php index f8860a7..3c6ea7c 100644 --- a/src/Profiles/BagItProfile.php +++ b/src/Profiles/BagItProfile.php @@ -412,13 +412,9 @@ public function isAllowFetchTxt(): bool /** * @param bool $allowFetchTxt Whether to allow the existence of a fetch.txt file. Default is true. * @return BagItProfile The profile object. - * @throws ProfileException If requireFetchTxt is true and allowFetchTxt is false. */ private function setAllowFetchTxt(?bool $allowFetchTxt): BagItProfile { - if ($this->requireFetchTxt === true && $allowFetchTxt === false) { - throw new ProfileException("Allow-Fetch.txt cannot be false if Require-Fetch.txt is true"); - } $this->allowFetchTxt = $allowFetchTxt ?? true; return $this; } diff --git a/tests/Profiles/BagProfileTest.php b/tests/Profiles/BagProfileTest.php index fdb9200..6749609 100644 --- a/tests/Profiles/BagProfileTest.php +++ b/tests/Profiles/BagProfileTest.php @@ -101,19 +101,6 @@ public function testAllowFetchInvalid(): void )); } - /** - * @group Profiles - * @covers ::setRequireFetchTxt - */ - public function testAllowFetchInvalid2(): void - { - $this->expectException(ProfileException::class); - $this->expectExceptionMessage("Allow-Fetch.txt cannot be false if Require-Fetch.txt is true"); - BagItProfile::fromJson(file_get_contents( - self::$profiles . "/test_profiles/allow_fetch_invalid_2.json" - )); - } - /** * @group Profiles * @covers ::setDataEmpty diff --git a/tests/resources/profiles/test_profiles/allow_fetch_invalid_2.json b/tests/resources/profiles/test_profiles/allow_fetch_invalid_2.json deleted file mode 100644 index cdfeef5..0000000 --- a/tests/resources/profiles/test_profiles/allow_fetch_invalid_2.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "BagIt-Profile-Info":{ - "BagIt-Profile-Identifier":"http://somewhere.org/my/profile.json", - "BagIt-Profile-Version": "0.1", - "Source-Organization":"Monsters, Inc.", - "Contact-Name":"Mike Wazowski", - "External-Description":"Profile for testing bad bag info tag options", - "Version":"0.3" - }, - "Allow-Fetch.txt": false, - "Require-Fetch.txt": true, - "Serialization": "forbidden", - "Accept-BagIt-Version":[ - "1.0" - ] -} \ No newline at end of file From a65cbab6c47ed96b55a24a10ca8cf7842be3c967 Mon Sep 17 00:00:00 2001 From: Jared Whiklo Date: Sat, 13 Apr 2024 12:50:15 -0500 Subject: [PATCH 12/21] Fix callable in array_filter --- src/BagUtils.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/BagUtils.php b/src/BagUtils.php index be8b1e6..1eb09da 100644 --- a/src/BagUtils.php +++ b/src/BagUtils.php @@ -139,7 +139,12 @@ public static function getAbsolute(string $path, bool $add_absolute = false): st } // Get and filter empty sub paths - $subPaths = array_filter(explode('/', $path), 'mb_strlen'); + $subPaths = array_filter( + explode('/', $path), + function ($i) { + return mb_strlen($i) > 0; + } + ); $absolutes = []; foreach ($subPaths as $subPath) { From 9ee89b14c4b10bf05174c28da8251028a8e5e58d Mon Sep 17 00:00:00 2001 From: Jared Whiklo Date: Sun, 14 Apr 2024 21:51:11 -0500 Subject: [PATCH 13/21] Move json into tests --- tests/Profiles/BagProfileTest.php | 234 ++++++++++++++++-- .../test_profiles/allow_fetch_invalid.json | 16 -- .../profiles/test_profiles/data_empty.json | 19 -- .../invalid_bag_info_tag_options.json | 24 -- .../profile_identifier_bag_info_tag.json | 19 -- .../set_manifest_allowed_invalid.json | 18 -- .../set_manifest_allowed_valid.json | 18 -- 7 files changed, 215 insertions(+), 133 deletions(-) delete mode 100644 tests/resources/profiles/test_profiles/allow_fetch_invalid.json delete mode 100644 tests/resources/profiles/test_profiles/data_empty.json delete mode 100644 tests/resources/profiles/test_profiles/invalid_bag_info_tag_options.json delete mode 100644 tests/resources/profiles/test_profiles/profile_identifier_bag_info_tag.json delete mode 100644 tests/resources/profiles/test_profiles/set_manifest_allowed_invalid.json delete mode 100644 tests/resources/profiles/test_profiles/set_manifest_allowed_valid.json diff --git a/tests/Profiles/BagProfileTest.php b/tests/Profiles/BagProfileTest.php index 6749609..d92cfb4 100644 --- a/tests/Profiles/BagProfileTest.php +++ b/tests/Profiles/BagProfileTest.php @@ -39,9 +39,35 @@ public function testValidateBag1(): void */ public function testInvalidBagInfoTags(): void { + $profileJson = <<< JSON +{ + "BagIt-Profile-Info":{ + "BagIt-Profile-Identifier":"http://somewhere.org/my/profile.json", + "BagIt-Profile-Version": "0.1", + "Source-Organization":"Monsters, Inc.", + "Contact-Name":"Mike Wazowski", + "External-Description":"Profile for testing bad bag info tag options", + "Version":"0.3" + }, + "Bag-Info":{ + "Source-Organization":{ + "required":true, + "values":[ + "Simon Fraser University", + "York University" + ], + "help": "This is the organization that originally created the bag." + } + }, + "Serialization": "forbidden", + "Accept-BagIt-Version":[ + "1.0" + ] +} +JSON; $this->expectException(ProfileException::class); $this->expectExceptionMessage("Invalid tag options for Source-Organization"); - BagItProfile::fromJson(file_get_contents(self::$profiles . "/test_profiles/invalid_bag_info_tag_options.json")); + BagItProfile::fromJson($profileJson); } /** @@ -51,9 +77,28 @@ public function testInvalidBagInfoTags(): void */ public function testBagItProfileIdentifierInTags(): void { - $profile = BagItProfile::fromJson(file_get_contents( - self::$profiles . "/test_profiles/profile_identifier_bag_info_tag.json" - )); + $profileJson = <<< JSON +{ + "BagIt-Profile-Info":{ + "BagIt-Profile-Identifier":"http://somewhere.org/my/profile.json", + "BagIt-Profile-Version": "0.1", + "Source-Organization":"Monsters, Inc.", + "Contact-Name":"Mike Wazowski", + "External-Description":"Profile for testing bad bag info tag options", + "Version":"0.3" + }, + "Bag-Info":{ + "BagIt-Profile-Identifier":{ + "required":true + } + }, + "Serialization": "forbidden", + "Accept-BagIt-Version":[ + "1.0" + ] +} +JSON; + $profile = BagItProfile::fromJson($profileJson); $this->assertArrayEquals( [ "The tag BagIt-Profile-Identifier is always required, but SHOULD NOT be listed under Bag-Info in " . @@ -69,9 +114,27 @@ public function testBagItProfileIdentifierInTags(): void */ public function testSetManifestAllowed(): void { - $profile = BagItProfile::fromJson(file_get_contents( - self::$profiles . "/test_profiles/set_manifest_allowed_valid.json" - )); + $profileJson = <<< JSON +{ + "BagIt-Profile-Info":{ + "BagIt-Profile-Identifier":"http://somewhere.org/my/profile.json", + "BagIt-Profile-Version": "0.1", + "Source-Organization":"Monsters, Inc.", + "Contact-Name":"Mike Wazowski", + "External-Description":"Profile for testing bad bag info tag options", + "Version":"0.3" + }, + "Manifests-Allowed": [ + "md5", + "sha256" + ], + "Serialization": "forbidden", + "Accept-BagIt-Version":[ + "1.0" + ] +} +JSON; + $profile = BagItProfile::fromJson($profileJson); $this->assertTrue($profile->isValid()); } @@ -81,11 +144,29 @@ public function testSetManifestAllowed(): void */ public function testSetManifestAllowedInvalid(): void { + $profileJson = <<< JSON +{ + "BagIt-Profile-Info":{ + "BagIt-Profile-Identifier":"http://somewhere.org/my/profile.json", + "BagIt-Profile-Version": "0.1", + "Source-Organization":"Monsters, Inc.", + "Contact-Name":"Mike Wazowski", + "External-Description":"Profile for testing bad bag info tag options", + "Version":"0.3" + }, + "Manifests-Required": [ + "md5", + "sha512" + ], + "Manifests-Allowed": [ + "md5", + "sha256" + ] +} +JSON; $this->expectException(ProfileException::class); $this->expectExceptionMessage("Manifests-Allowed must include all entries from Manifests-Required"); - BagItProfile::fromJson(file_get_contents( - self::$profiles . "/test_profiles/set_manifest_allowed_invalid.json" - )); + BagItProfile::fromJson($profileJson); } /** @@ -94,11 +175,57 @@ public function testSetManifestAllowedInvalid(): void */ public function testAllowFetchInvalid(): void { + $profileJson = <<< JSON +{ + "BagIt-Profile-Info":{ + "BagIt-Profile-Identifier":"http://somewhere.org/my/profile.json", + "BagIt-Profile-Version": "0.1", + "Source-Organization":"Monsters, Inc.", + "Contact-Name":"Mike Wazowski", + "External-Description":"Profile for testing bad bag info tag options", + "Version":"0.3" + }, + "Require-Fetch.txt": true, + "Allow-Fetch.txt": false, + "Serialization": "forbidden", + "Accept-BagIt-Version":[ + "1.0" + ] +} +JSON; $this->expectException(ProfileException::class); $this->expectExceptionMessage("Allow-Fetch.txt cannot be false if Require-Fetch.txt is true"); - BagItProfile::fromJson(file_get_contents( - self::$profiles . "/test_profiles/allow_fetch_invalid.json" - )); + BagItProfile::fromJson($profileJson); + } + + /** + * @group Profiles + * @covers ::setRequireFetchTxt + * @covers ::isRequireFetchTxt + */ + public function testRequireFetchValid(): void + { + $profileJson = <<< JSON +{ + "BagIt-Profile-Info":{ + "BagIt-Profile-Identifier":"http://somewhere.org/my/profile.json", + "BagIt-Profile-Version": "0.1", + "Source-Organization":"Monsters, Inc.", + "Contact-Name":"Mike Wazowski", + "External-Description":"Profile for testing bad bag info tag options", + "Version":"0.3" + }, + "Require-Fetch.txt": true, + "Allow-Fetch.txt": true, + "Serialization": "forbidden", + "Accept-BagIt-Version":[ + "1.0" + ] +} +JSON; + $profile = BagItProfile::fromJson($profileJson); + $this->assertTrue($profile->isValid()); + $this->assertTrue($profile->isRequireFetchTxt()); } /** @@ -108,9 +235,28 @@ public function testAllowFetchInvalid(): void */ public function testDataEmpty(): void { - $profile = BagItProfile::fromJson(file_get_contents( - self::$profiles . "/test_profiles/data_empty.json" - )); + $profileJson = <<< JSON +{ + "BagIt-Profile-Info":{ + "BagIt-Profile-Identifier":"http://somewhere.org/my/profile.json", + "BagIt-Profile-Version": "0.1", + "Source-Organization":"Monsters, Inc.", + "Contact-Name":"Mike Wazowski", + "External-Description":"Profile for testing bad bag info tag options", + "Version":"0.3" + }, +"Manifests-Allowed": [ +"md5", +"sha512" +], +"Data-Empty": true, +"Serialization": "forbidden", +"Accept-BagIt-Version":[ +"1.0" +] +} +JSON; + $profile = BagItProfile::fromJson($profileJson); $this->assertTrue($profile->isValid()); $bag = Bag::create($this->tmpdir); $bag->update(); @@ -149,9 +295,28 @@ public function testDataEmpty(): void */ public function testDataEmpty2(): void { - $profile = BagItProfile::fromJson(file_get_contents( - self::$profiles . "/test_profiles/data_empty.json" - )); + $profileJson = <<< JSON +{ + "BagIt-Profile-Info":{ + "BagIt-Profile-Identifier":"http://somewhere.org/my/profile.json", + "BagIt-Profile-Version": "0.1", + "Source-Organization":"Monsters, Inc.", + "Contact-Name":"Mike Wazowski", + "External-Description":"Profile for testing bad bag info tag options", + "Version":"0.3" + }, +"Manifests-Allowed": [ +"md5", +"sha512" +], +"Data-Empty": true, +"Serialization": "forbidden", +"Accept-BagIt-Version":[ +"1.0" +] +} +JSON; + $profile = BagItProfile::fromJson($profileJson); $this->assertTrue($profile->isValid()); $bag = Bag::create($this->tmpdir); $bag->update(); @@ -166,4 +331,35 @@ public function testDataEmpty2(): void "file but it contains a single file of size 398246"); $profile->validateBag($bag); } + + /** + * @group Profiles + * @covers ::setTagManifestsAllowed + * @covers ::getTagManifestsAllowed + */ + public function testTagManifestAllowed(): void + { + $profileJson = <<< JSON +{ + "BagIt-Profile-Info":{ + "BagIt-Profile-Identifier":"http://somewhere.org/my/profile.json", + "BagIt-Profile-Version": "0.1", + "Source-Organization":"Monsters, Inc.", + "Contact-Name":"Mike Wazowski", + "External-Description":"Profile for testing bad bag info tag options", + "Version":"0.3" + }, + "Tag-Manifests-Allowed": [ + "md5" + ], + "Serialization": "forbidden", + "Accept-BagIt-Version":[ + "1.0" + ] +} +JSON; + $profile = BagItProfile::fromJson($profileJson); + $this->assertTrue($profile->isValid()); + $this->assertArrayEquals(["md5"], $profile->getTagManifestsAllowed()); + } } diff --git a/tests/resources/profiles/test_profiles/allow_fetch_invalid.json b/tests/resources/profiles/test_profiles/allow_fetch_invalid.json deleted file mode 100644 index 3f86f0b..0000000 --- a/tests/resources/profiles/test_profiles/allow_fetch_invalid.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "BagIt-Profile-Info":{ - "BagIt-Profile-Identifier":"http://somewhere.org/my/profile.json", - "BagIt-Profile-Version": "0.1", - "Source-Organization":"Monsters, Inc.", - "Contact-Name":"Mike Wazowski", - "External-Description":"Profile for testing bad bag info tag options", - "Version":"0.3" - }, - "Require-Fetch.txt": true, - "Allow-Fetch.txt": false, - "Serialization": "forbidden", - "Accept-BagIt-Version":[ - "1.0" - ] -} \ No newline at end of file diff --git a/tests/resources/profiles/test_profiles/data_empty.json b/tests/resources/profiles/test_profiles/data_empty.json deleted file mode 100644 index 968af9f..0000000 --- a/tests/resources/profiles/test_profiles/data_empty.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "BagIt-Profile-Info":{ - "BagIt-Profile-Identifier":"http://somewhere.org/my/profile.json", - "BagIt-Profile-Version": "0.1", - "Source-Organization":"Monsters, Inc.", - "Contact-Name":"Mike Wazowski", - "External-Description":"Profile for testing bad bag info tag options", - "Version":"0.3" - }, - "Manifests-Allowed": [ - "md5", - "sha512" - ], - "Data-Empty": true, - "Serialization": "forbidden", - "Accept-BagIt-Version":[ - "1.0" - ] -} \ No newline at end of file diff --git a/tests/resources/profiles/test_profiles/invalid_bag_info_tag_options.json b/tests/resources/profiles/test_profiles/invalid_bag_info_tag_options.json deleted file mode 100644 index 3152d5e..0000000 --- a/tests/resources/profiles/test_profiles/invalid_bag_info_tag_options.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "BagIt-Profile-Info":{ - "BagIt-Profile-Identifier":"http://somewhere.org/my/profile.json", - "BagIt-Profile-Version": "0.1", - "Source-Organization":"Monsters, Inc.", - "Contact-Name":"Mike Wazowski", - "External-Description":"Profile for testing bad bag info tag options", - "Version":"0.3" - }, - "Bag-Info":{ - "Source-Organization":{ - "required":true, - "values":[ - "Simon Fraser University", - "York University" - ], - "help": "This is the organization that originally created the bag." - } - }, - "Serialization": "forbidden", - "Accept-BagIt-Version":[ - "1.0" - ] -} \ No newline at end of file diff --git a/tests/resources/profiles/test_profiles/profile_identifier_bag_info_tag.json b/tests/resources/profiles/test_profiles/profile_identifier_bag_info_tag.json deleted file mode 100644 index d7a2944..0000000 --- a/tests/resources/profiles/test_profiles/profile_identifier_bag_info_tag.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "BagIt-Profile-Info":{ - "BagIt-Profile-Identifier":"http://somewhere.org/my/profile.json", - "BagIt-Profile-Version": "0.1", - "Source-Organization":"Monsters, Inc.", - "Contact-Name":"Mike Wazowski", - "External-Description":"Profile for testing bad bag info tag options", - "Version":"0.3" - }, - "Bag-Info":{ - "BagIt-Profile-Identifier":{ - "required":true - } - }, - "Serialization": "forbidden", - "Accept-BagIt-Version":[ - "1.0" - ] -} \ No newline at end of file diff --git a/tests/resources/profiles/test_profiles/set_manifest_allowed_invalid.json b/tests/resources/profiles/test_profiles/set_manifest_allowed_invalid.json deleted file mode 100644 index 0b20c8b..0000000 --- a/tests/resources/profiles/test_profiles/set_manifest_allowed_invalid.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "BagIt-Profile-Info":{ - "BagIt-Profile-Identifier":"http://somewhere.org/my/profile.json", - "BagIt-Profile-Version": "0.1", - "Source-Organization":"Monsters, Inc.", - "Contact-Name":"Mike Wazowski", - "External-Description":"Profile for testing bad bag info tag options", - "Version":"0.3" - }, - "Manifests-Required": [ - "md5", - "sha512" - ], - "Manifests-Allowed": [ - "md5", - "sha256" - ] -} \ No newline at end of file diff --git a/tests/resources/profiles/test_profiles/set_manifest_allowed_valid.json b/tests/resources/profiles/test_profiles/set_manifest_allowed_valid.json deleted file mode 100644 index b0840c3..0000000 --- a/tests/resources/profiles/test_profiles/set_manifest_allowed_valid.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "BagIt-Profile-Info":{ - "BagIt-Profile-Identifier":"http://somewhere.org/my/profile.json", - "BagIt-Profile-Version": "0.1", - "Source-Organization":"Monsters, Inc.", - "Contact-Name":"Mike Wazowski", - "External-Description":"Profile for testing bad bag info tag options", - "Version":"0.3" - }, - "Manifests-Allowed": [ - "md5", - "sha256" - ], - "Serialization": "forbidden", - "Accept-BagIt-Version":[ - "1.0" - ] -} \ No newline at end of file From 859c8298a8e7be6b75d98d6de50b337e86c2103c Mon Sep 17 00:00:00 2001 From: Jared Whiklo Date: Sun, 14 Apr 2024 22:02:59 -0500 Subject: [PATCH 14/21] More tests --- src/Bag.php | 8 -------- tests/Profiles/BagProfileTest.php | 31 +++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/src/Bag.php b/src/Bag.php index 8e20c7f..b6943fc 100644 --- a/src/Bag.php +++ b/src/Bag.php @@ -1268,14 +1268,6 @@ public function removeTagFile(string $dest): void $this->changed = true; } - /** - * @return bool True if the bag was loaded from a serialized format. - */ - public function hasSerialization(): bool - { - return $this->serialization_extension !== null; - } - /** * @return string|null The serialization format if the bag was loaded from a serialized format or null. */ diff --git a/tests/Profiles/BagProfileTest.php b/tests/Profiles/BagProfileTest.php index d92cfb4..80018e9 100644 --- a/tests/Profiles/BagProfileTest.php +++ b/tests/Profiles/BagProfileTest.php @@ -172,6 +172,7 @@ public function testSetManifestAllowedInvalid(): void /** * @group Profiles * @covers ::setAllowFetchTxt + * @covers ::setRequireFetchTxt */ public function testAllowFetchInvalid(): void { @@ -201,6 +202,7 @@ public function testAllowFetchInvalid(): void /** * @group Profiles * @covers ::setRequireFetchTxt + * @covers ::setAllowFetchTxt * @covers ::isRequireFetchTxt */ public function testRequireFetchValid(): void @@ -362,4 +364,33 @@ public function testTagManifestAllowed(): void $this->assertTrue($profile->isValid()); $this->assertArrayEquals(["md5"], $profile->getTagManifestsAllowed()); } + + public function testTagFilesMissingFromAllowed(): void + { + $profileJson = <<< JSON +{ + "BagIt-Profile-Info":{ + "BagIt-Profile-Identifier":"http://somewhere.org/my/profile.json", + "BagIt-Profile-Version": "0.1", + "Source-Organization":"Monsters, Inc.", + "Contact-Name":"Mike Wazowski", + "External-Description":"Profile for testing tag files required and allowed", + "Version":"0.3" + }, + "Tag-Files-Required": [ + "Special-tag-file.txt" + ], + "Tag-Files-Allowed": [ + "Special-tag-file.txt", + "Another-special-tag-file.txt" + ], + "Serialization": "forbidden", + "Accept-BagIt-Version":[ + "1.0" + ] +} +JSON; + $profile = BagItProfile::fromJson($profileJson); + $this->assertTrue($profile->isValid()); + } } From 25243f8704a41ea27f798f0180cb7913be731b23 Mon Sep 17 00:00:00 2001 From: Jared Whiklo Date: Wed, 24 Apr 2024 12:57:01 -0500 Subject: [PATCH 15/21] Add methods to add profile to bag and some tests --- src/Bag.php | 114 +++++++++++++++++++++++++---- src/Profiles/BagItProfile.php | 12 +-- tests/BagItWebserverFramework.php | 35 +++++++-- tests/Profiles/BagProfileTest.php | 93 +++++++++++++++++++++++ tests/Profiles/ProfileWebTests.php | 103 ++++++++++++++++++++++++++ 5 files changed, 328 insertions(+), 29 deletions(-) create mode 100644 tests/Profiles/ProfileWebTests.php diff --git a/src/Bag.php b/src/Bag.php index b6943fc..4a4e3db 100644 --- a/src/Bag.php +++ b/src/Bag.php @@ -8,6 +8,9 @@ use Normalizer; use whikloj\BagItTools\Exceptions\BagItException; use whikloj\BagItTools\Exceptions\FilesystemException; +use whikloj\BagItTools\Exceptions\ProfileException; +use whikloj\BagItTools\Profiles\BagItProfile; +use whikloj\BagItTools\Profiles\ProfileFactory; use ZipArchive; /** @@ -134,14 +137,14 @@ class Bag /** * All the extensions in one array. * - * @var array + * @var array */ private array $packageExtensions; /** * Array of current bag version with keys 'major' and 'minor'. * - * @var array + * @var array */ private array $currentVersion = self::DEFAULT_BAGIT_VERSION; @@ -155,21 +158,21 @@ class Bag /** * Array of payload manifests. * - * @var array + * @var array */ private array $payloadManifests; /** * Array of tag manifests. * - * @var array + * @var array */ private array $tagManifests; /** * List of relative file paths for all files. * - * @var array + * @var array */ private array $payloadFiles; @@ -201,21 +204,21 @@ class Bag * supported by the BagIt specification. Stored to avoid extraneous calls * to hash_algos(). * - * @var array + * @var array */ private array $validHashAlgorithms; /** * Errors when validating a bag. * - * @var array + * @var array> */ private array $bagErrors; /** * Warnings when validating a bag. * - * @var array + * @var array> */ private array $bagWarnings; @@ -229,7 +232,7 @@ class Bag /** * Bag Info data. * - * @var array + * @var array> */ private array $bagInfoData = []; @@ -237,7 +240,7 @@ class Bag * Unique array of all Bag info tags/values. Tags are stored once in lower case with an array of all instances * of values. This index does not save order. * - * @var array + * @var array> */ private array $bagInfoTagIndex = []; @@ -254,6 +257,12 @@ class Bag */ private ?string $serialization = null; + /** + * Array of BagIt profiles. + * @var array + */ + private array $profiles = []; + /** * Bag constructor. * @@ -361,6 +370,16 @@ public function isValid(): bool $this->mergeErrors($manifest->getErrors()); $this->mergeWarnings($manifest->getWarnings()); } + foreach ($this->profiles as $profile) { + try { + $profile->validateBag($this); + } catch (ProfileException $e) { + $this->addBagError( + $profile->getProfileIdentifier(), + $e->getMessage() + ); + } + } return (count($this->bagErrors) == 0); } @@ -1276,10 +1295,74 @@ public function getSerializationMimeType(): ?string return $this->serialization; } + /** + * @return array The profiles that have been added to the bag. + */ + public function getBagProfiles(): array + { + return $this->profiles; + } + + /** + * Add a profile to the bag. + * @param string $url The URL to the profile. + * @throws Exceptions\ProfileException If the profile cannot be loaded or parsed. + */ + public function addBagProfileByURL(string $url): void + { + $this->addBagProfileInternal(ProfileFactory::generateProfileFromUri($url)); + } + + /** + * Add a profile to the bag. + * @param string $json The JSON representation of the profile. + * @throws Exceptions\ProfileException If the profile cannot be parsed. + */ + public function addBagProfileByJson(string $json): void + { + $this->addBagProfileInternal(BagItProfile::fromJson($json)); + } + + /** + * Remove a profile from the bag. + * @param string $profileId The identifier of the profile to remove. + */ + public function removeBagProfile(string $profileId): void + { + if (array_key_exists($profileId, $this->profiles)) { + unset($this->profiles[$profileId]); + $this->changed = true; + } + } + + /** + * Clear all profiles from the bag. + */ + public function clearAllProfiles(): void + { + if (count($this->profiles) > 0) { + $this->profiles = []; + $this->changed = true; + } + } + /* * XXX: Private functions */ + /** + * Add a profile to the bag if it doesn't already exist. + * @param BagItProfile $profile The profile to add. + */ + private function addBagProfileInternal(BagItProfile $profile): void + { + if (!array_key_exists($profile->getProfileIdentifier(), $this->profiles)) { + $this->setExtended(true); + $this->profiles[$profile->getProfileIdentifier()] = $profile; + $this->changed = true; + } + } + /** * Common checks for interactions with custom tag files. * @param string $tagFilePath The relative path to the tag file. @@ -1963,8 +2046,8 @@ function (&$item) { ); } else { $this->currentVersion = [ - 'major' => $match[1], - 'minor' => $match[2], + 'major' => (int)$match[1], + 'minor' => (int)$match[2], ]; } if ( @@ -2188,7 +2271,12 @@ private static function untarBag(string $filename): string private static function extensionTarCompression(string $filename): ?string { $filename = strtolower(basename($filename)); - return (str_ends_with($filename, '.bz2') ? 'bz2' : (str_ends_with($filename, 'gz') ? 'gz' : null)); + if (str_ends_with($filename, '.bz2')) { + return 'bz2'; + } elseif (str_ends_with($filename, '.gz') || str_ends_with($filename, '.tgz')) { + return 'gz'; + } + return null; } /** diff --git a/src/Profiles/BagItProfile.php b/src/Profiles/BagItProfile.php index 3c6ea7c..a6991b1 100644 --- a/src/Profiles/BagItProfile.php +++ b/src/Profiles/BagItProfile.php @@ -923,8 +923,7 @@ public function validateBag(Bag $bag): bool } if ($this->getManifestsRequired() !== []) { $manifests = array_keys($bag->getPayloadManifests()); - $diff = array_diff($manifests, $this->getManifestsRequired()) + - array_diff($this->getManifestsRequired(), $manifests); + $diff = array_diff($this->getManifestsRequired(), $manifests); if ($diff !== []) { $errors[] = "Profile requires payload manifest(s) which are missing from the bag (" . implode(", ", $diff) . ")"; @@ -940,8 +939,7 @@ public function validateBag(Bag $bag): bool } if ($this->getTagManifestsRequired() !== []) { $manifests = array_keys($bag->getTagManifests()); - $diff = array_diff($manifests, $this->getTagManifestsRequired()) + - array_diff($this->getTagManifestsRequired(), $manifests); + $diff = array_diff($this->getTagManifestsRequired(), $manifests); if ($diff !== []) { $errors[] = "Profile requires tag manifest(s) which are missing from the bag (" . implode(", ", $diff) . ")"; @@ -959,8 +957,7 @@ public function validateBag(Bag $bag): bool // Grab the first tag manifest, they should all be the same $manifests = $bag->getTagManifests()[0]; $tag_files = array_keys($manifests->getHashes()); - $diff = array_diff($this->getTagFilesRequired(), $tag_files) + - array_diff($tag_files, $this->getTagFilesRequired()); + $diff = array_diff($this->getTagFilesRequired(), $tag_files); if ($diff !== []) { $errors[] = "Profile requires tag files(s) which are missing from the bag (" . implode(", ", $diff) . ")"; @@ -980,8 +977,7 @@ public function validateBag(Bag $bag): bool // Grab the first tag manifest, they should all be the same $manifests = $bag->getPayloadManifests()[0]; $payload_files = array_keys($manifests->getHashes()); - $diff = array_diff($this->getPayloadFilesRequired(), $payload_files) + - array_diff($payload_files, $this->getPayloadFilesRequired()); + $diff = array_diff($this->getPayloadFilesRequired(), $payload_files); if ($diff !== []) { $errors[] = "Profile requires payload file(s) which are missing from the bag (" . implode(", ", $diff) . ")"; diff --git a/tests/BagItWebserverFramework.php b/tests/BagItWebserverFramework.php index 56ad14f..4532047 100644 --- a/tests/BagItWebserverFramework.php +++ b/tests/BagItWebserverFramework.php @@ -5,10 +5,25 @@ use donatj\MockWebServer\MockWebServer; use donatj\MockWebServer\Response; +/** + * Class to setup a mock webserver for testing remote file downloads. + * @package whikloj\BagItTools\Test + * @since 5.0.0 + * + * To use this abstract class, extend it and then implement the setupBeforeClass methods, define the webserver_files + * variable and then call the parent::setUpBeforeClass() method. + */ abstract class BagItWebserverFramework extends BagItTestFramework { /** * Array of remote files defined in mock webserver. + * Outside key is a unique identifier, keys for the inside array are: + * filename (string) - path to file with response contents + * headers (array) - headers to return in response + * status_code (int) - status code + * content (string) - string to return, used instead of filename + * path (path) - the path of the URL, used if filename not defined. Otherwise is basename(filename) + * @var array> */ protected static array $webserver_files = []; @@ -22,19 +37,20 @@ abstract class BagItWebserverFramework extends BagItTestFramework /** * Array of file contents for use with comparing against requests against the same index in self::$remote_urls * - * @var string|array|false + * @var array */ - protected static string|array|false $response_content = []; + protected static array $response_content = []; /** * Array of mock urls to get responses from. Match response bodies against matching key in self::$response_content * - * @var string|array + * @var array */ - protected static string|array $remote_urls = []; + protected static array $remote_urls = []; /** * {@inheritdoc} + * NOTE: You should override this in your class, define self::$webserver_files, then call parent::setUpBeforeClass */ public static function setUpBeforeClass(): void { @@ -42,14 +58,17 @@ public static function setUpBeforeClass(): void self::$webserver->start(); $counter = 0; foreach (self::$webserver_files as $file) { - self::$response_content[$counter] = file_get_contents($file['filename']); + self::$response_content[$counter] = $file['content'] ?? file_get_contents($file['filename']); // Add custom headers if defined. - $response_headers = [ 'Cache-Control' => 'no-cache', 'Content-Length' => stat($file['filename'])['size']] + - ($file['headers'] ?? []); + $response_headers = [ + 'Cache-Control' => 'no-cache', + 'Content-Length' => isset($file['content']) ? strlen($file['content']) : + stat($file['filename'])['size'] + ] + ($file['headers'] ?? []); // Use custom status code if defined. $status_code = $file['status_code'] ?? 200; self::$remote_urls[$counter] = self::$webserver->setResponseOfPath( - "/example/" . basename($file['filename']), + "/" . ($file['path'] ?? "example/" . basename($file['filename'])), new Response( self::$response_content[$counter], $response_headers, diff --git a/tests/Profiles/BagProfileTest.php b/tests/Profiles/BagProfileTest.php index 80018e9..639fca5 100644 --- a/tests/Profiles/BagProfileTest.php +++ b/tests/Profiles/BagProfileTest.php @@ -365,6 +365,11 @@ public function testTagManifestAllowed(): void $this->assertArrayEquals(["md5"], $profile->getTagManifestsAllowed()); } + /** + * @group Profiles + * @covers ::setTagFilesAllowed + * @covers ::getTagFilesRequired + */ public function testTagFilesMissingFromAllowed(): void { $profileJson = <<< JSON @@ -393,4 +398,92 @@ public function testTagFilesMissingFromAllowed(): void $profile = BagItProfile::fromJson($profileJson); $this->assertTrue($profile->isValid()); } + + /** + * @group Profiles + * @covers \whikloj\BagItTools\Bag::isValid + * @covers \whikloj\BagItTools\Bag::addBagProfileByJson + * @covers ::validateBag + */ + public function testAddProfileToBag(): void + { + $profileJson = <<assertTrue($profile->isValid()); + $bag = Bag::create($this->tmpdir); + $bag->addBagProfileByJson($profileJson); + $this->assertFalse($bag->isValid()); + $bag->addBagInfoTag("Source-Organization", "Simon Fraser University"); + $bag->addBagInfoTag("Contact-Phone", "555-555-5555"); + $this->assertFalse($bag->isValid()); + $this->assertCount(1, $bag->getErrors()); + $error = $bag->getErrors()[0]; + $this->assertEquals( + [ + "file" => "http://example.profile.org/bagit-test-profile.json", + "message" => "Profile requires payload manifest(s) which are missing from the bag (md5)" + ], + $error + ); + $bag->setAlgorithm("md5"); + $this->assertTrue($bag->isValid()); + } + + /** + * @group Profiles + * @covers \whikloj\BagItTools\Bag::addBagProfileByJson + * @covers \whikloj\BagItTools\Bag::addBagProfileInternal + * @covers \whikloj\BagItTools\Bag::removeBagProfile + */ + public function testAddSameProfileTwice(): void + { + $profileJson = file_get_contents(self::$profiles . "/bagProfileFoo.json"); + $bag = Bag::create($this->tmpdir); + $this->assertCount(0, $bag->getBagProfiles()); + $bag->addBagProfileByJson($profileJson); + $this->assertCount(1, $bag->getBagProfiles()); + $bag->addBagProfileByJson($profileJson); + $this->assertCount(1, $bag->getBagProfiles()); + $bag->removeBagProfile("http://some.incorrect.identifier"); + $this->assertCount(1, $bag->getBagProfiles()); + $bag->removeBagProfile("http://www.library.yale.edu/mssa/bagitprofiles/disk_images.json"); + $this->assertCount(0, $bag->getBagProfiles()); + } } diff --git a/tests/Profiles/ProfileWebTests.php b/tests/Profiles/ProfileWebTests.php new file mode 100644 index 0000000..6167207 --- /dev/null +++ b/tests/Profiles/ProfileWebTests.php @@ -0,0 +1,103 @@ + [ + 'content' => $profileJson, + 'path' => 'bagit-test-profile.json', + ], + ]; + parent::setUpBeforeClass(); + } + + public function testAddProfileToBagUri(): void + { + $profile = ProfileFactory::generateProfileFromUri(self::$remote_urls[0]); + $this->assertTrue($profile->isValid()); + $bag = Bag::create($this->tmpdir); + $bag->addBagProfileByURL(self::$remote_urls[0]); + $this->assertFalse($bag->isValid()); + $bag->addBagInfoTag("Source-Organization", "Simon Fraser University"); + $bag->addBagInfoTag("Contact-Phone", "555-555-5555"); + $this->assertFalse($bag->isValid()); + $this->assertCount(1, $bag->getErrors()); + $error = $bag->getErrors()[0]; + $this->assertEquals( + [ + "file" => "http://example.profile.org/bagit-test-profile.json", + "message" => "Profile requires payload manifest(s) which are missing from the bag (md5)" + ], + $error + ); + $bag->setAlgorithm("md5"); + $this->assertTrue($bag->isValid()); + } + + /** + * @group Profiles + * @covers \whikloj\BagItTools\Bag::addBagProfileByJson + * @covers \whikloj\BagItTools\Bag::addBagProfileInternal + * @covers \whikloj\BagItTools\Bag::removeBagProfile + */ + public function testAddSameProfileTwiceByUri(): void + { + $bag = Bag::create($this->tmpdir); + $this->assertCount(0, $bag->getBagProfiles()); + $bag->addBagProfileByURL(self::$remote_urls[0]); + $this->assertCount(1, $bag->getBagProfiles()); + $bag->addBagProfileByURL(self::$remote_urls[0]); + $this->assertCount(1, $bag->getBagProfiles()); + $bag->removeBagProfile("http://some.incorrect.identifier"); + $this->assertCount(1, $bag->getBagProfiles()); + $bag->removeBagProfile("http://www.library.yale.edu/mssa/bagitprofiles/disk_images.json"); + $this->assertCount(0, $bag->getBagProfiles()); + } +} From e6906c52fd04c56c2ab24dde6232bdadf753c19c Mon Sep 17 00:00:00 2001 From: Jared Whiklo Date: Wed, 24 Apr 2024 13:39:00 -0500 Subject: [PATCH 16/21] Allow other bag-info tag options --- src/Profiles/BagItProfile.php | 5 -- src/Profiles/ProfileTags.php | 41 +++++++++++- tests/Profiles/BagProfileTest.php | 100 ++++++++++++++++++++++++++++-- 3 files changed, 136 insertions(+), 10 deletions(-) diff --git a/src/Profiles/BagItProfile.php b/src/Profiles/BagItProfile.php index a6991b1..e1e900b 100644 --- a/src/Profiles/BagItProfile.php +++ b/src/Profiles/BagItProfile.php @@ -337,16 +337,11 @@ public function getBagInfoTags(): array /** * @param array $bagInfoTags Parsed profile Bag-Info sections * @return BagItProfile The profile object. - * @throws ProfileException If invalid options are specified for a tag. */ private function setBagInfoTags(array $bagInfoTags): BagItProfile { - $expectedKeys = ['required' => 0, 'values' => 0, 'repeatable' => 0, 'description' => 0]; $this->profileBagInfoTags = []; foreach ($bagInfoTags as $tagName => $tagOpts) { - if (count(array_diff_key($tagOpts, $expectedKeys)) > 0) { - throw new ProfileException("Invalid tag options for $tagName"); - } if (self::matchStrings('BagIt-Profile-Identifier', $tagName)) { $this->profileWarnings[] = "The tag BagIt-Profile-Identifier is always required, but SHOULD NOT be " . "listed under Bag-Info in the Profile."; diff --git a/src/Profiles/ProfileTags.php b/src/Profiles/ProfileTags.php index 2d0adba..1c66c68 100644 --- a/src/Profiles/ProfileTags.php +++ b/src/Profiles/ProfileTags.php @@ -13,6 +13,11 @@ */ class ProfileTags { + /** + * Array with keys matching optional keys from specification, all other keys are system specific. + */ + private const SPEC_TAGS = ['required' => 0, 'values' => 0, 'repeatable' => 0, 'description' => 0]; + /** * @var string */ @@ -38,6 +43,11 @@ class ProfileTags */ private string $description = ""; + /** + * @var array + */ + private array $otherOptions = []; + /** * ProfileTags constructor. * @param string $tag @@ -95,14 +105,43 @@ public function getDescription(): string return $this->description; } + /** + * Return any tags defined in the BagItProfile but not in the specification. + * @return array Array of tagName => tagValue + */ + public function getOtherTagOptions(): array + { + return $this->otherOptions; + } + + /** + * Set the other tag options. + * @param array $tagOptions Array of optionName => optionValue + */ + protected function setOtherTagOptions(array $tagOptions): void + { + $this->otherOptions = $tagOptions; + } + + /** + * Create a ProfileTags object from a JSON array. + * @param string $tag Tag name + * @param array $tagOpts Tag options + * @return ProfileTags The created object. + */ public static function fromJson(string $tag, array $tagOpts): ProfileTags { - return new ProfileTags( + $profileTag = new ProfileTags( $tag, $tagOpts['required'] ?? false, $tagOpts['values'] ?? [], $tagOpts['repeatable'] ?? true, $tagOpts['description'] ?? "" ); + $otherTags = array_diff_key($tagOpts, self::SPEC_TAGS); + if (count($otherTags) > 0) { + $profileTag->setOtherTagOptions($otherTags); + } + return $profileTag; } } diff --git a/tests/Profiles/BagProfileTest.php b/tests/Profiles/BagProfileTest.php index 639fca5..0ccf2fc 100644 --- a/tests/Profiles/BagProfileTest.php +++ b/tests/Profiles/BagProfileTest.php @@ -37,7 +37,7 @@ public function testValidateBag1(): void * @group Profiles * @covers ::setBagInfoTags */ - public function testInvalidBagInfoTags(): void + public function testOptionalBagInfoTags(): void { $profileJson = <<< JSON { @@ -65,9 +65,21 @@ public function testInvalidBagInfoTags(): void ] } JSON; - $this->expectException(ProfileException::class); - $this->expectExceptionMessage("Invalid tag options for Source-Organization"); - BagItProfile::fromJson($profileJson); + $profile = BagItProfile::fromJson($profileJson); + $this->assertTrue($profile->isValid()); + $this->assertArrayEquals(["source-organization"], array_keys($profile->getBagInfoTags())); + $tag = $profile->getBagInfoTags()["source-organization"]; + $this->assertEquals("Source-Organization", $tag->getTag()); + $this->assertTrue($tag->isRequired()); + $this->assertArrayEquals(["Simon Fraser University", "York University"], $tag->getValues()); + $this->assertEquals("", $tag->getDescription()); + $this->assertTrue($tag->isRepeatable()); + $this->assertArrayEquals( + [ + "help" => "This is the organization that originally created the bag." + ], + $tag->getOtherTagOptions() + ); } /** @@ -486,4 +498,84 @@ public function testAddSameProfileTwice(): void $bag->removeBagProfile("http://www.library.yale.edu/mssa/bagitprofiles/disk_images.json"); $this->assertCount(0, $bag->getBagProfiles()); } + + /** + * @group Profiles + * @covers \whikloj\BagItTools\Bag::addBagProfileByJson + * @covers \whikloj\BagItTools\Bag::addBagProfileInternal + * @covers \whikloj\BagItTools\Bag::removeBagProfile + */ + public function testAddDifferentProfiles(): void + { + $profile1 = file_get_contents(self::$profiles . "/bagProfileFoo.json"); + $profile2 = file_get_contents(self::$profiles . "/bagProfileBar.json"); + $profile3 = file_get_contents(self::$profiles . "/btrProfile.json"); + $bag = Bag::create($this->tmpdir); + $this->assertCount(0, $bag->getBagProfiles()); + $bag->addBagProfileByJson($profile1); + $this->assertCount(1, $bag->getBagProfiles()); + $bag->addBagProfileByJson($profile2); + $this->assertCount(2, $bag->getBagProfiles()); + $bag->addBagProfileByJson($profile3); + $this->assertCount(3, $bag->getBagProfiles()); + // Add profile a second time has no effect. + $bag->addBagProfileByJson($profile2); + $this->assertCount(3, $bag->getBagProfiles()); + $this->assertArrayEquals( + [ + "http://canadiana.org/standards/bagit/tdr_ingest.json", + "https://github.com/dpscollaborative/btr_bagit_profile/releases/download/1.0/btr-bagit-profile.json", + "http://www.library.yale.edu/mssa/bagitprofiles/disk_images.json", + ], + array_keys($bag->getBagProfiles()) + ); + // Remove profiles. + $bag->removeBagProfile("http://canadiana.org/standards/bagit/tdr_ingest.json"); + $this->assertCount(2, $bag->getBagProfiles()); + $bag->removeBagProfile( + "https://github.com/dpscollaborative/btr_bagit_profile/releases/download/1.0/btr-bagit-profile.json" + ); + $this->assertCount(1, $bag->getBagProfiles()); + // Remove profile that doesn't exist in bag has no effect + $bag->removeBagProfile("http://canadiana.org/standards/bagit/tdr_ingest.json"); + $this->assertCount(1, $bag->getBagProfiles()); + $bag->removeBagProfile("http://www.library.yale.edu/mssa/bagitprofiles/disk_images.json"); + $this->assertCount(0, $bag->getBagProfiles()); + } + + /** + * @group Profiles + * @covers \whikloj\BagItTools\Bag::clearAllProfiles + */ + public function testClearAllProfiles(): void + { + $profile1 = file_get_contents(self::$profiles . "/bagProfileFoo.json"); + $profile2 = file_get_contents(self::$profiles . "/bagProfileBar.json"); + $profile3 = file_get_contents(self::$profiles . "/btrProfile.json"); + $bag = Bag::create($this->tmpdir); + $this->assertCount(0, $bag->getBagProfiles()); + $bag->addBagProfileByJson($profile1); + $bag->addBagProfileByJson($profile2); + $bag->addBagProfileByJson($profile3); + $this->assertCount(3, $bag->getBagProfiles()); + $bag->clearAllProfiles(); + $this->assertCount(0, $bag->getBagProfiles()); + } + + /** + * @group Profiles + * @covers \whikloj\BagItTools\Bag::getBagProfiles + */ + public function testGetBagProfile(): void + { + $profile = file_get_contents(self::$profiles . "/bagProfileBar.json"); + $bag = Bag::create($this->tmpdir); + $bag->addBagProfileByJson($profile); + $testProfiles = $bag->getBagProfiles(); + $this->assertCount(1, $testProfiles); + $key = key($testProfiles); + $val = current($testProfiles); + $this->assertEquals("http://canadiana.org/standards/bagit/tdr_ingest.json", $key); + $this->assertInstanceOf(BagItProfile::class, $val); + } } From 4a4671ee5565e38152ca489ae74110eb2c5dade5 Mon Sep 17 00:00:00 2001 From: Jared Whiklo Date: Wed, 24 Apr 2024 13:57:11 -0500 Subject: [PATCH 17/21] Test bag is valid before creating a package --- README.md | 34 +++++++++++++++++++++++++++++++--- src/Bag.php | 5 ++++- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1886059..0a741f0 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,13 @@ The required extensions are: You can integrate BagItTools into your own code as a library using the [API](#api), or use the CLI commands for some simple functionality. +## BagIt Profile Support (v5.0) +You can now add BagIt Profile(s) to a newly created bag and/or they will be downloaded and parsed when validating an +existing bag, assuming the profile is available at the URL specified in the bag-info.txt file. + +Profiles are validated against the [BagIt Profile Specification (v1.4.0)](https://bagit-profiles.github.io/bagit-profiles-specification/) +and profile rules are enforced when validating a bag (`$bag->isValid()`) and any errors are displayed in the `$bag->getErrors()` array. + ### Command line #### Validating a bag @@ -169,12 +176,33 @@ if ($bag->hasBagInfoTag('contact-name')) { $bag->removeBagInfoTag('contact-name'); } -// Write bagit support files (manifests, bag-info, etc) -$bag->update(); - // Write the bag to the specified path and filename using the expected archiving method. $bag->package('./archive.tar.bz2'); +``` + +#### Using a BagIt Profile +```php +require_once './vendor/autoload.php'; + +use \whikloj\BagItTools\Bag; + +$dir = "./newbag"; +// Create new bag as directory $dir +$bag = Bag::create($dir); +// Add a profile by URL +$bag->addBagProfileByURL("https://some.example.com/bagit-profile.json"); +// or add a profile by JSON +$bag->addBagProfileByJson(file_get_contents("path/to/bagit-profile.json")); + +// Add a file +$bag->addFile('../README.md', 'data/documentation/myreadme.md'); + +// Add another algorithm +$bag->addAlgorithm('sha1'); + +// Write the bag to the specified path and filename using the expected archiving method. +$bag->package('./archive.tar.bz2'); ``` ## Maintainer diff --git a/src/Bag.php b/src/Bag.php index 4a4e3db..0b40b73 100644 --- a/src/Bag.php +++ b/src/Bag.php @@ -430,7 +430,7 @@ public function finalize(): void * @param string $filepath * The full path to create the archive at. * @throws BagItException - * Problems creating the archive. + * Problems creating the archive or if Bag is not valid. */ public function package(string $filepath): void { @@ -441,6 +441,9 @@ public function package(string $filepath): void ); } $this->finalize(); + if (!$this->isValid()) { + throw new BagItException("Bag is not valid, cannot package."); + } $this->makePackage($filepath); } From 564df32fc3c6fbd1945ca5331fe341319527f394 Mon Sep 17 00:00:00 2001 From: Jared Whiklo Date: Wed, 24 Apr 2024 16:11:16 -0500 Subject: [PATCH 18/21] Read BagIt Profile identifiers from bag-info.txt and validate Additional tests Handle long lines without a space to break at --- src/Bag.php | 47 +++++++----- src/Profiles/BagItProfile.php | 22 ++++-- tests/Profiles/ProfileWebTests.php | 76 ++++++++++++++++++- .../resources/profiles/test-profile-bag.json | 35 +++++++++ 4 files changed, 155 insertions(+), 25 deletions(-) create mode 100644 tests/resources/profiles/test-profile-bag.json diff --git a/src/Bag.php b/src/Bag.php index 0b40b73..e089c2d 100644 --- a/src/Bag.php +++ b/src/Bag.php @@ -112,6 +112,17 @@ class Bag '.zip', ]; + /** + * All the extensions in one array. + */ + private const PACKAGE_EXTENSIONS = [ + '.tar', + '.tgz', + '.tar.gz', + '.tar.bz2', + '.zip', + ]; + /** * Length we start trying to wrap at. */ @@ -134,13 +145,6 @@ class Bag '.zip' => 'application/zip', ]; - /** - * All the extensions in one array. - * - * @var array - */ - private array $packageExtensions; - /** * Array of current bag version with keys 'major' and 'minor'. * @@ -280,7 +284,6 @@ class Bag */ private function __construct(string $rootPath, bool $new = true, ?string $extension = null) { - $this->packageExtensions = array_merge(self::TAR_EXTENSIONS, self::ZIP_EXTENSIONS); // Define valid hash algorithms our PHP supports. $this->validHashAlgorithms = array_filter( hash_algos(), @@ -434,10 +437,10 @@ public function finalize(): void */ public function package(string $filepath): void { - if (!self::hasExtension(self::getExtension($filepath), $this->packageExtensions)) { + if (!self::hasExtension(self::getExtension($filepath), self::PACKAGE_EXTENSIONS)) { throw new BagItException( "Unknown archive type ($filepath), the file extension must be one of (" . - implode(", ", $this->packageExtensions) . ")" + implode(", ", self::PACKAGE_EXTENSIONS) . ")" ); } $this->finalize(); @@ -1494,17 +1497,20 @@ private function loadBagInfo(): bool } $line = $this->decodeText($line) . PHP_EOL; $lineLength = strlen($line); - if (str_starts_with($line, " ") || $line[0] == "\t") { + if (str_starts_with($line, " ") || str_starts_with($line, "\t")) { // Continuation of a line if (count($bagData) > 0) { $previousValue = $bagData[count($bagData) - 1]['value']; // Add a space only if the previous character was not a line break. - $lastChar = substr($previousValue, -1); + $lastCharIsNewline = str_ends_with($previousValue, "\n") || + str_ends_with($previousValue, "\r"); if ($lineLength >= Bag::BAGINFO_AUTOWRAP_GUESS_LENGTH) { // Line is max length or longer, should be autowrapped $previousValue = rtrim($previousValue, "\r\n"); } - $previousValue .= ($lastChar != "\r" && $lastChar != "\n" ? " " : ""); + // If the line was too long but had no spaces, it would end up with a previous value of nothing. + // That would cause a space to be added to the beginning of the next line. + $previousValue .= ($lastCharIsNewline || $previousValue == "") ? "" : " "; $previousValue .= Bag::trimSpacesOnly($line); $bagData[count($bagData) - 1]['value'] = $previousValue; } else { @@ -1534,7 +1540,7 @@ private function loadBagInfo(): bool ); } $value = $matches[4]; - if ($lineLength < Bag::BAGINFO_AUTOWRAP_GUESS_LENGTH) { + if ($lineLength < Bag::BAGINFO_AUTOWRAP_GUESS_LENGTH && $value !== "") { // Shorter line, re-add the newline removed by the preg_match. $value .= PHP_EOL; } @@ -1553,6 +1559,11 @@ private function loadBagInfo(): bool $this->bagInfoData = $bagData; $this->updateBagInfoIndex(); + if ($this->hasBagInfoTag(BagItProfile::BAGIT_PROFILE_IDENTIFIER)) { + foreach ($this->getBagInfoByTag(BagItProfile::BAGIT_PROFILE_IDENTIFIER) as $profile) { + $this->addBagProfileByURL($profile); + } + } return true; } @@ -2610,17 +2621,17 @@ private function mergeWarnings(array $newWarnings): void /** * Determine the serialization mimetype from the extension. - * @param string $extension The extension. + * @param string $fileExtension The extension. * @return string The serialization mimetype. * @throws BagItException If the serialization mimetype cannot be determined. */ - private function determineSerializationMimetype(string $extension): string + private function determineSerializationMimetype(string $fileExtension): string { foreach (self::SERIALIZATION_MAPPING as $extension => $mimetype) { - if (str_ends_with($extension, $extension)) { + if (str_ends_with($fileExtension, $extension)) { return $mimetype; } } - throw new BagItException("Unable to determine serialization mimetype for extension ($extension)."); + throw new BagItException("Unable to determine serialization mimetype for extension ($fileExtension)."); } } diff --git a/src/Profiles/BagItProfile.php b/src/Profiles/BagItProfile.php index e1e900b..1403df7 100644 --- a/src/Profiles/BagItProfile.php +++ b/src/Profiles/BagItProfile.php @@ -18,6 +18,11 @@ */ class BagItProfile { + /** + * @var string The tag for the profile identifier and resolvable URI. + */ + public const BAGIT_PROFILE_IDENTIFIER = "BagIt-Profile-Identifier"; + /** * @var string * The identifier (and resolvable URI) of the BagItProfile. @@ -950,12 +955,17 @@ public function validateBag(Bag $bag): bool } if ($this->getTagFilesRequired() !== []) { // Grab the first tag manifest, they should all be the same - $manifests = $bag->getTagManifests()[0]; - $tag_files = array_keys($manifests->getHashes()); - $diff = array_diff($this->getTagFilesRequired(), $tag_files); - if ($diff !== []) { - $errors[] = "Profile requires tag files(s) which are missing from the bag (" . - implode(", ", $diff) . ")"; + $manifests = $bag->getTagManifests(); + if (count($manifests) === 0) { + $errors[] = "Profile requires tag files but the bag has no tag manifests"; + } else { + $manifest = reset($manifests); + $tag_files = array_keys($manifest->getHashes()); + $diff = array_diff($this->getTagFilesRequired(), $tag_files); + if ($diff !== []) { + $errors[] = "Profile requires tag files(s) which are missing from the bag (" . + implode(", ", $diff) . ")"; + } } } if ($this->getTagFilesAllowed() !== []) { diff --git a/tests/Profiles/ProfileWebTests.php b/tests/Profiles/ProfileWebTests.php index 6167207..a68c11f 100644 --- a/tests/Profiles/ProfileWebTests.php +++ b/tests/Profiles/ProfileWebTests.php @@ -3,7 +3,7 @@ namespace whikloj\BagItTools\Test\Profiles; use whikloj\BagItTools\Bag; -use whikloj\BagItTools\Profiles\BagItProfile; +use whikloj\BagItTools\Exceptions\BagItException; use whikloj\BagItTools\Profiles\ProfileFactory; use whikloj\BagItTools\Test\BagItWebserverFramework; @@ -54,10 +54,19 @@ public static function setUpBeforeClass(): void 'content' => $profileJson, 'path' => 'bagit-test-profile.json', ], + 'test-profile-bag.json' => [ + 'filename' => self::TEST_RESOURCES . '/profiles/test-profile-bag.json', + ], ]; parent::setUpBeforeClass(); } + /** + * @group Profiles + * @covers \whikloj\BagItTools\Bag::addBagProfileByURL + * @covers \whikloj\BagItTools\Bag::addBagProfileInternal + * @covers \whikloj\BagItTools\Profiles\BagItProfile::validateBag + */ public function testAddProfileToBagUri(): void { $profile = ProfileFactory::generateProfileFromUri(self::$remote_urls[0]); @@ -100,4 +109,69 @@ public function testAddSameProfileTwiceByUri(): void $bag->removeBagProfile("http://www.library.yale.edu/mssa/bagitprofiles/disk_images.json"); $this->assertCount(0, $bag->getBagProfiles()); } + + /** + * @group Profiles + * @covers \whikloj\BagItTools\Profiles\BagItProfile::validateBag + */ + public function testBagDoesntSupportSerialization(): void + { + $bag = Bag::create($this->tmpdir); + $bag->addBagInfoTag('BagIt-Profile-Identifier', trim(self::$remote_urls[1])); + $bag->addBagInfoTag('Contact-Name', 'Some Person'); + $bag->addBagInfoTag('Contact-Phone', '555-555-5555'); + $bag->addBagInfoTag('Contact-Email', 'some.person@noreply.org'); + $bag->addBagInfoTag('Contact-Address', '1234 Some Street, Some City, Some State, 12345'); + $bag->addBagInfoTag('Source-Organization', 'BagItTools'); + $tmpfile = $this->getTempName(); + file_put_contents($tmpfile, "CUSTOM-TAG-ID: 1234\nCUSTOM-TAG-ORG: 5678\n"); + $bag->addTagFile($tmpfile, 'tagFiles/special-tags.txt'); + $bag->createFile( + "This is an example test file in the TestProfileBag. It is used to test the\n" . + "validation of a profile.", + "example-file.txt" + ); + $bag->addAlgorithm('sha1'); + $tmpPackage = $this->getTempName() . ".tgz"; + $bag->package($tmpPackage); + $this->assertFileExists($tmpPackage); + + $new_bag = Bag::load($tmpPackage); + $this->assertFalse($new_bag->isValid()); + $this->assertCount(1, $new_bag->getErrors()); + $error = $new_bag->getErrors()[0]; + $this->assertEquals( + [ + "file" => "http://example.org/example/test-profile-bag.json", + "message" => "Profile allows for serialization MIME type (application/zip) but the bag has MIME " . + "type (application/gzip)" + ], + $error + ); + } + + /** + * @group Profiles + * @covers \whikloj\BagItTools\Profiles\BagItProfile::validateBag + */ + public function testProfileMissingRequiredTag(): void + { + $bag = Bag::create($this->tmpdir); + $bag->addBagInfoTag('BagIt-Profile-Identifier', trim(self::$remote_urls[1])); + $bag->addBagInfoTag('Contact-Name', 'Some Person'); + $bag->addBagInfoTag('Source-Organization', 'BagItTools'); + $tmpfile = $this->getTempName(); + file_put_contents($tmpfile, "CUSTOM-TAG-ID: 1234\nCUSTOM-TAG-ORG: 5678\n"); + $bag->addTagFile($tmpfile, 'tagFiles/special-tags.txt'); + $bag->createFile( + "This is an example test file in the TestProfileBag. It is used to test the\n" . + "validation of a profile.", + "example-file.txt" + ); + $bag->addAlgorithm('sha1'); + $tmpPackage = $this->getTempName() . ".zip"; + $this->expectException(BagItException::class); + $this->expectExceptionMessage("Bag is not valid, cannot package."); + $bag->package($tmpPackage); + } } diff --git a/tests/resources/profiles/test-profile-bag.json b/tests/resources/profiles/test-profile-bag.json new file mode 100644 index 0000000..334e0ff --- /dev/null +++ b/tests/resources/profiles/test-profile-bag.json @@ -0,0 +1,35 @@ +{ + "BagIt-Profile-Info":{ + "BagIt-Profile-Identifier":"http://example.org/example/test-profile-bag.json", + "BagIt-Profile-Version": "1.4.0", + "Source-Organization":"BagItTools", + "Contact-Name":"BagItTools Developers", + "External-Description":"BagIt Profile for testing loading of a bag with a profile", + "Version":"1.2" + }, + "Bag-Info": { + "Contact-Name": { + "required": true + }, + "Contact-Email": { + "required": true + }, + "Source-Organization": { + "required": true, + "values": [ + "BagItTools" + ] + } + }, + "Manifests-Required":[ + "sha1" + ], + "Accept-Serialization":[ + "application/zip" + ], + "Tag-Files-Required":[ + "bagit.txt", + "bag-info.txt", + "tagFiles/special-tags.txt" + ] +} \ No newline at end of file From b9b988024ef5017fbad58e9af2d3f85053569a16 Mon Sep 17 00:00:00 2001 From: Jared Whiklo Date: Wed, 24 Apr 2024 16:33:48 -0500 Subject: [PATCH 19/21] Add a change log --- CHANGELOG.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ README.md | 15 ++------------- 2 files changed, 46 insertions(+), 13 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2cbd423 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,44 @@ +# Change Log + +The purpose of this document is to provide a list of changes included in a release under the covers that +may have an impact on the user. This is not a comprehensive list of all changes, but hopefully catches most and +provides a reasoning for the changes. + +## v5.0.0 + +### Added + +#### BagIt Profile support. + +You can now add BagIt Profile(s) to a newly created bag and/or they will be downloaded and parsed when validating an +existing bag, assuming the profile is available at the URL specified in the bag-info.txt file. + +Profiles are validated against the [BagIt Profile Specification (v1.4.0)](https://bagit-profiles.github.io/bagit-profiles-specification/) +and profile rules are enforced when validating a bag (`$bag->isValid()`) and any errors are displayed in the `$bag->getErrors()` array. + +To add a profile to a bag you can use either: +- `$bag->addProfileByJson($jsonString)` - To add a profile from a JSON string. +- `$bag->addProfileByURL($url)` - To add a profile from a URL. + +Profiles are stored internally using their `BagIt-Profile-Identifier` as a key. You can only add a profile once +per identifier. If you try to add a profile with the same identifier it will be ignored. + +To remove a profile you can use `$bag->removeBagProfile($profileIdentifier)` to remove a profile. + +You can also use `$bag->clearAllProfiles()` to remove all profiles from a bag. + +#### Package command validates the bag + +Previous versions allowed you to package without validating the bag. Now the package command will validate the bag +before packaging. If the bag is not valid the package command will fail with a `BagItException::class` being thrown. + +This is due to the addition of BagIt Profile support, if you add a profile to a bag we want to ensure you do not package +an invalid bag. + +TODO: Validate the serialization being attempted during package validation. + +### Removed + +#### PHP 7 Support + +This library now requires PHP 8.0 or higher, while PHP 8.0 is already end of life we will support it for the time being. diff --git a/README.md b/README.md index 0a741f0..4874667 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ Features: * Create an archive (zip, tar, tar.gz, tgz, tar.bz2) * In-place upgrade of bag from v0.97 to v1.0 +## [Change Log](./CHANGELOG.md) + ## Installation **Composer** @@ -60,13 +62,6 @@ The required extensions are: You can integrate BagItTools into your own code as a library using the [API](#api), or use the CLI commands for some simple functionality. -## BagIt Profile Support (v5.0) -You can now add BagIt Profile(s) to a newly created bag and/or they will be downloaded and parsed when validating an -existing bag, assuming the profile is available at the URL specified in the bag-info.txt file. - -Profiles are validated against the [BagIt Profile Specification (v1.4.0)](https://bagit-profiles.github.io/bagit-profiles-specification/) -and profile rules are enforced when validating a bag (`$bag->isValid()`) and any errors are displayed in the `$bag->getErrors()` array. - ### Command line #### Validating a bag @@ -212,9 +207,3 @@ $bag->package('./archive.tar.bz2'); ## License [MIT](./LICENSE) - -## Development - -To-Do: - -* CLI interface to handle simple bag CRUD (CReate/Update/Delete) functions. From fe216d9862d6ede3512cd4bca812c5d28d9c77e6 Mon Sep 17 00:00:00 2001 From: Jared Whiklo Date: Wed, 1 May 2024 20:36:05 -0500 Subject: [PATCH 20/21] Some tidying --- composer.json | 4 +--- composer.lock | 64 +-------------------------------------------------- src/Bag.php | 16 +++++++------ 3 files changed, 11 insertions(+), 73 deletions(-) diff --git a/composer.json b/composer.json index f928f5a..b8806c8 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,6 @@ }, "require-dev": { "phpunit/phpunit": "^9.6", - "sebastian/phpcpd": "^6.0", "squizlabs/php_codesniffer": "^3.5", "donatj/mock-webserver": "^2.6", "phpstan/phpstan": "^1.4" @@ -46,8 +45,7 @@ "php -d xdebug.mode=profile -d xdebug.output_dir=mytracedir/ -d xdebug.start_with_request=yes -d xdebug.use_compression=true ./vendor/bin/phpunit" ], "check": [ - "./vendor/bin/phpcs --standard=PSR12 src tests", - "./vendor/bin/phpcpd --suffix='.php' src" + "./vendor/bin/phpcs --standard=PSR12 src tests" ], "phpunit": [ "phpdbg -qrr ./vendor/bin/phpunit -d memory_limit=-1 --verbose --testsuite BagIt" diff --git a/composer.lock b/composer.lock index edabb9d..0145a8c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "91066452d862fde4d8e86cb1e5719c03", + "content-hash": "fce1174f3dd45f07a3ae898d0702db6a", "packages": [ { "name": "pear/archive_tar", @@ -2764,68 +2764,6 @@ ], "time": "2020-10-26T13:14:26+00:00" }, - { - "name": "sebastian/phpcpd", - "version": "6.0.3", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/phpcpd.git", - "reference": "f3683aa0db2e8e09287c2bb33a595b2873ea9176" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpcpd/zipball/f3683aa0db2e8e09287c2bb33a595b2873ea9176", - "reference": "f3683aa0db2e8e09287c2bb33a595b2873ea9176", - "shasum": "" - }, - "require": { - "ext-dom": "*", - "php": ">=7.3", - "phpunit/php-file-iterator": "^3.0", - "phpunit/php-timer": "^5.0", - "sebastian/cli-parser": "^1.0", - "sebastian/version": "^3.0" - }, - "bin": [ - "phpcpd" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "6.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Copy/Paste Detector (CPD) for PHP code.", - "homepage": "https://github.com/sebastianbergmann/phpcpd", - "support": { - "issues": "https://github.com/sebastianbergmann/phpcpd/issues", - "source": "https://github.com/sebastianbergmann/phpcpd/tree/6.0.3" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "abandoned": true, - "time": "2020-12-07T05:39:23+00:00" - }, { "name": "sebastian/recursion-context", "version": "4.0.5", diff --git a/src/Bag.php b/src/Bag.php index e089c2d..bd35f9d 100644 --- a/src/Bag.php +++ b/src/Bag.php @@ -500,6 +500,7 @@ public function addFile(string $source, string $dest): void * The relative path of the file. * @throws FilesystemException * Issues deleting the file. + * @throws BagItException If the destination is outside the data directory. */ public function removeFile(string $dest): void { @@ -559,7 +560,7 @@ public function makeAbsolute(string $path): string $components = array_filter(explode("/", $path)); $rootComponents = array_filter(explode("/", $this->bagRoot)); $components = array_merge($rootComponents, $components); - $prefix = (preg_match('/^[a-z]:/i', $rootComponents[0] ?? '', $matches) ? '' : '/'); + $prefix = (preg_match('/^[a-z]:/i', $rootComponents[0] ?? '') ? '' : '/'); return $prefix . implode('/', $components); } @@ -1475,6 +1476,7 @@ private function updateFetch(): void * * @throws FilesystemException * Unable to read bag-info.txt + * @throws ProfileException Unable to load or parse the BagIt profile. */ private function loadBagInfo(): bool { @@ -1717,10 +1719,10 @@ private function calculateTotalFileSizeAndAmountOfFiles(): ?array $fullPath = $this->makeAbsolute($file); if (file_exists($fullPath) && is_file($fullPath)) { $info = stat($fullPath); - if (!isset($info[7])) { + if (!isset($info["size"])) { return null; } - $total_size += (int) $info[7]; + $total_size += (int) $info["size"]; $total_files += 1; } } @@ -2049,7 +2051,7 @@ function (&$item) { } if ( !preg_match( - "~^BagIt\-Version: (\d+)\.(\d+)$~", + "~^BagIt-Version: (\d+)\.(\d+)$~", $lines[0], $match ) @@ -2066,7 +2068,7 @@ function (&$item) { } if ( !preg_match( - "~^Tag\-File\-Character\-Encoding: (.*)$~", + "~^Tag-File-Character-Encoding: (.*)$~", $lines[1], $match ) @@ -2510,11 +2512,11 @@ private function hashIsSupported(string $internal_name): bool * Case-insensitive version of array_key_exists * * @param string $search The key to look for. - * @param string|int $key The associative or numeric key to look in. + * @param int|string $key The associative or numeric key to look in. * @param array $map The associative array to search. * @return boolean True if the key exists regardless of case. */ - private static function arrayKeyExistsNoCase(string $search, $key, array $map): bool + private static function arrayKeyExistsNoCase(string $search, int|string $key, array $map): bool { $keys = array_column($map, $key); array_walk( From 93c328bdda22e756c5289355cf42feca20591599 Mon Sep 17 00:00:00 2001 From: Jared Whiklo Date: Tue, 19 Nov 2024 14:32:38 -0600 Subject: [PATCH 21/21] Correct PIPEWAIT and add test for trait --- src/CurlInstance.php | 10 +++++----- tests/CurlInstanceTest.php | 30 ++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) create mode 100644 tests/CurlInstanceTest.php diff --git a/src/CurlInstance.php b/src/CurlInstance.php index 202fce4..ccb60ce 100644 --- a/src/CurlInstance.php +++ b/src/CurlInstance.php @@ -94,18 +94,18 @@ private static function setupCurl(string $curlVersion): array if (!defined('CURLMOPT_MAX_TOTAL_CONNECTIONS')) { define('CURLMOPT_MAX_TOTAL_CONNECTIONS', 13); } - if (!defined('CURL_PIPEWAIT')) { - define('CURL_PIPEWAIT', 237); + if (!defined('CURLOPT_PIPEWAIT')) { + define('CURLOPT_PIPEWAIT', 237); } $curlOptions = [ CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_RETURNTRANSFER => true, ]; if ( - version_compare('7.0', PHP_VERSION) <= 0 && - version_compare('7.43.0', $curlVersion) <= 0 + version_compare('7.0', PHP_VERSION, '<=') && + version_compare('7.43.0', $curlVersion, '<=') ) { - $curlOptions[CURL_PIPEWAIT] = true; + $curlOptions[CURLOPT_PIPEWAIT] = true; } return $curlOptions; } diff --git a/tests/CurlInstanceTest.php b/tests/CurlInstanceTest.php new file mode 100644 index 0000000..981222f --- /dev/null +++ b/tests/CurlInstanceTest.php @@ -0,0 +1,30 @@ +createCurl('http://example.com'); + $this->assertInstanceOf(\CurlHandle::class, $handle); + } + + public function testCurlMulti() + { + $mock = new class + { + use CurlInstance; + }; // anonymous class + + $handle = $mock->createMultiCurl(); + $this->assertInstanceOf(\CurlMultiHandle::class, $handle); + } +}