diff --git a/src/SPC/builder/LibraryBase.php b/src/SPC/builder/LibraryBase.php index 6780a695..f62e297b 100644 --- a/src/SPC/builder/LibraryBase.php +++ b/src/SPC/builder/LibraryBase.php @@ -51,7 +51,7 @@ abstract class LibraryBase // if source is locked as pre-built, we just tryInstall it $pre_built_name = Downloader::getPreBuiltLockName($source); if (isset($lock[$pre_built_name]) && ($lock[$pre_built_name]['lock_as'] ?? SPC_DOWNLOAD_SOURCE) === SPC_DOWNLOAD_PRE_BUILT) { - return $this->tryInstall($lock[$pre_built_name]['filename'], $force); + return $this->tryInstall($lock[$pre_built_name], $force); } return $this->tryBuild($force); } @@ -166,14 +166,15 @@ abstract class LibraryBase * @throws WrongUsageException * @throws FileSystemException */ - public function tryInstall(string $install_file, bool $force_install = false): int + public function tryInstall(array $lock, bool $force_install = false): int { + $install_file = $lock['filename']; if ($force_install) { logger()->info('Installing required library [' . static::NAME . '] from pre-built binaries'); // Extract files try { - FileSystem::extractPackage($install_file, DOWNLOAD_PATH . '/' . $install_file, BUILD_ROOT_PATH); + FileSystem::extractPackage($install_file, $lock['source_type'], DOWNLOAD_PATH . '/' . $install_file, BUILD_ROOT_PATH); $this->install(); return LIB_STATUS_OK; } catch (FileSystemException|RuntimeException $e) { @@ -183,19 +184,19 @@ abstract class LibraryBase } foreach ($this->getStaticLibs() as $name) { if (!file_exists(BUILD_LIB_PATH . "/{$name}")) { - $this->tryInstall($install_file, true); + $this->tryInstall($lock, true); return LIB_STATUS_OK; } } foreach ($this->getHeaders() as $name) { if (!file_exists(BUILD_INCLUDE_PATH . "/{$name}")) { - $this->tryInstall($install_file, true); + $this->tryInstall($lock, true); return LIB_STATUS_OK; } } // pkg-config is treated specially. If it is pkg-config, check if the pkg-config binary exists if (static::NAME === 'pkg-config' && !file_exists(BUILD_ROOT_PATH . '/bin/pkg-config')) { - $this->tryInstall($install_file, true); + $this->tryInstall($lock, true); return LIB_STATUS_OK; } return LIB_STATUS_ALREADY; diff --git a/src/SPC/command/DeleteDownloadCommand.php b/src/SPC/command/DeleteDownloadCommand.php index b68b8618..12b0b420 100644 --- a/src/SPC/command/DeleteDownloadCommand.php +++ b/src/SPC/command/DeleteDownloadCommand.php @@ -60,7 +60,7 @@ class DeleteDownloadCommand extends BaseCommand foreach ($deleted_sources as $lock_name) { // remove download file/dir if exists - if ($lock[$lock_name]['source_type'] === 'archive') { + if ($lock[$lock_name]['source_type'] === SPC_SOURCE_ARCHIVE) { if (file_exists($path = FileSystem::convertPath(DOWNLOAD_PATH . '/' . $lock[$lock_name]['filename']))) { logger()->info('Deleting file ' . $path); unlink($path); diff --git a/src/SPC/doctor/item/LinuxMuslCheck.php b/src/SPC/doctor/item/LinuxMuslCheck.php index bd89c0d7..15516355 100644 --- a/src/SPC/doctor/item/LinuxMuslCheck.php +++ b/src/SPC/doctor/item/LinuxMuslCheck.php @@ -78,7 +78,7 @@ class LinuxMuslCheck ]; logger()->info('Downloading ' . $musl_source['url']); Downloader::downloadSource($musl_version_name, $musl_source); - FileSystem::extractSource($musl_version_name, DOWNLOAD_PATH . "/{$musl_version_name}.tar.gz"); + FileSystem::extractSource($musl_version_name, SPC_SOURCE_ARCHIVE, DOWNLOAD_PATH . "/{$musl_version_name}.tar.gz"); // Apply CVE-2025-26519 patch SourcePatcher::patchFile('musl-1.2.5_CVE-2025-26519_0001.patch', SOURCE_PATH . "/{$musl_version_name}"); diff --git a/src/SPC/store/Downloader.php b/src/SPC/store/Downloader.php index 7efcfb3b..b0c663d3 100644 --- a/src/SPC/store/Downloader.php +++ b/src/SPC/store/Downloader.php @@ -208,7 +208,7 @@ class Downloader if ($download_as === SPC_DOWNLOAD_PRE_BUILT) { $name = self::getPreBuiltLockName($name); } - self::lockSource($name, ['source_type' => 'archive', 'filename' => $filename, 'move_path' => $move_path, 'lock_as' => $download_as]); + self::lockSource($name, ['source_type' => SPC_SOURCE_ARCHIVE, 'filename' => $filename, 'move_path' => $move_path, 'lock_as' => $download_as]); } /** @@ -231,6 +231,9 @@ class Downloader } else { $lock = json_decode(FileSystem::readFile(DOWNLOAD_PATH . '/.lock.json'), true) ?? []; } + // calculate hash + $hash = self::getLockSourceHash($data); + $data['hash'] = $hash; $lock[$name] = $data; FileSystem::writeFile(DOWNLOAD_PATH . '/.lock.json', json_encode($lock, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); } @@ -278,7 +281,7 @@ class Downloader } // Lock logger()->debug("Locking git source {$name}"); - self::lockSource($name, ['source_type' => 'dir', 'dirname' => $name, 'move_path' => $move_path, 'lock_as' => $lock_as]); + self::lockSource($name, ['source_type' => SPC_SOURCE_GIT, 'dirname' => $name, 'move_path' => $move_path, 'lock_as' => $lock_as]); /* // 复制目录过去 @@ -371,6 +374,16 @@ class Downloader SPC_DOWNLOAD_PRE_BUILT ); break; + case 'local': + // Local directory, do nothing, just lock it + logger()->debug("Locking local source {$name}"); + self::lockSource($name, [ + 'source_type' => SPC_SOURCE_LOCAL, + 'dirname' => $pkg['dirname'], + 'move_path' => $pkg['extract'] ?? null, + 'lock_as' => SPC_DOWNLOAD_PACKAGE, + ]); + break; case 'custom': // Custom download method, like API-based download or other $classes = FileSystem::getClassesPsr4(ROOT_DIR . '/src/SPC/store/source', 'SPC\store\source'); foreach ($classes as $class) { @@ -477,6 +490,16 @@ class Downloader $download_as ); break; + case 'local': + // Local directory, do nothing, just lock it + logger()->debug("Locking local source {$name}"); + self::lockSource($name, [ + 'source_type' => SPC_SOURCE_LOCAL, + 'dirname' => $source['dirname'], + 'move_path' => $source['extract'] ?? null, + 'lock_as' => $download_as, + ]); + break; case 'custom': // Custom download method, like API-based download or other if (isset($source['func']) && is_callable($source['func'])) { $source['name'] = $name; @@ -594,6 +617,43 @@ class Downloader return "{$source}-" . PHP_OS_FAMILY . '-' . getenv('GNU_ARCH') . '-' . (getenv('SPC_LIBC') ?: 'default') . '-' . (SystemUtil::getLibcVersionIfExists() ?? 'default'); } + /** + * Get the hash of the lock source based on the lock options. + * + * @param array $lock_options Lock options + * @return string Hash of the lock source + * @throws RuntimeException + */ + public static function getLockSourceHash(array $lock_options): string + { + $result = match ($lock_options['source_type']) { + SPC_SOURCE_ARCHIVE => sha1_file(DOWNLOAD_PATH . '/' . $lock_options['filename']), + SPC_SOURCE_GIT => exec('cd ' . escapeshellarg(DOWNLOAD_PATH . '/' . $lock_options['dirname']) . ' && ' . SPC_GIT_EXEC . ' rev-parse HEAD'), + SPC_SOURCE_LOCAL => 'LOCAL HASH IS ALWAYS DIFFERENT', + default => filter_var(getenv('SPC_IGNORE_BAD_HASH'), FILTER_VALIDATE_BOOLEAN) ? '' : throw new RuntimeException("Unknown source type: {$lock_options['source_type']}"), + }; + if ($result === false && !filter_var(getenv('SPC_IGNORE_BAD_HASH'), FILTER_VALIDATE_BOOLEAN)) { + throw new RuntimeException("Failed to get hash for source: {$lock_options['source_type']}"); + } + return $result ?: ''; + } + + /** + * @param array $lock_options Lock options + * @param string $destination Target directory + * @throws FileSystemException + * @throws RuntimeException + */ + public static function putLockSourceHash(array $lock_options, string $destination): void + { + $hash = self::getLockSourceHash($lock_options); + if ($lock_options['source_type'] === SPC_SOURCE_LOCAL) { + logger()->debug("Source [{$lock_options['dirname']}] is local, no hash will be written."); + return; + } + FileSystem::writeFile("{$destination}/.spc-hash", $hash); + } + /** * Register CTRL+C event for different OS. * @@ -640,8 +700,8 @@ class Downloader // If lock file exists, skip downloading for source mode if (!$force && $download_as === SPC_DOWNLOAD_SOURCE && isset($lock[$name])) { if ( - $lock[$name]['source_type'] === 'archive' && file_exists(DOWNLOAD_PATH . '/' . $lock[$name]['filename']) || - $lock[$name]['source_type'] === 'dir' && is_dir(DOWNLOAD_PATH . '/' . $lock[$name]['dirname']) + $lock[$name]['source_type'] === SPC_SOURCE_ARCHIVE && file_exists(DOWNLOAD_PATH . '/' . $lock[$name]['filename']) || + $lock[$name]['source_type'] === SPC_SOURCE_GIT && is_dir(DOWNLOAD_PATH . '/' . $lock[$name]['dirname']) ) { logger()->notice("Source [{$name}] already downloaded: " . ($lock[$name]['filename'] ?? $lock[$name]['dirname'])); return true; @@ -652,8 +712,8 @@ class Downloader if (!$force && $download_as === SPC_DOWNLOAD_PRE_BUILT && isset($lock[$lock_name = self::getPreBuiltLockName($name)])) { // lock name with env if ( - $lock[$lock_name]['source_type'] === 'archive' && file_exists(DOWNLOAD_PATH . '/' . $lock[$lock_name]['filename']) || - $lock[$lock_name]['source_type'] === 'dir' && is_dir(DOWNLOAD_PATH . '/' . $lock[$lock_name]['dirname']) + $lock[$lock_name]['source_type'] === SPC_SOURCE_ARCHIVE && file_exists(DOWNLOAD_PATH . '/' . $lock[$lock_name]['filename']) || + $lock[$lock_name]['source_type'] === SPC_SOURCE_GIT && is_dir(DOWNLOAD_PATH . '/' . $lock[$lock_name]['dirname']) ) { logger()->notice("Pre-built content [{$name}] already downloaded: " . ($lock[$lock_name]['filename'] ?? $lock[$lock_name]['dirname'])); return true; diff --git a/src/SPC/store/FileSystem.php b/src/SPC/store/FileSystem.php index 16047427..d67ed6cf 100644 --- a/src/SPC/store/FileSystem.php +++ b/src/SPC/store/FileSystem.php @@ -142,7 +142,7 @@ class FileSystem * @throws RuntimeException * @throws FileSystemException */ - public static function extractPackage(string $name, string $filename, ?string $extract_path = null): void + public static function extractPackage(string $name, string $source_type, string $filename, ?string $extract_path = null): void { if ($extract_path !== null) { // replace @@ -151,14 +151,15 @@ class FileSystem } else { $extract_path = PKG_ROOT_PATH . '/' . $name; } - logger()->info("extracting {$name} package to {$extract_path} ..."); + logger()->info("Extracting {$name} package to {$extract_path} ..."); $target = self::convertPath($extract_path); if (!is_dir($dir = dirname($target))) { self::createDir($dir); } try { - self::extractArchive($filename, $target); + // extract wrapper command + self::extractWithType($source_type, $filename, $extract_path); } catch (RuntimeException $e) { if (PHP_OS_FAMILY === 'Windows') { f_passthru('rmdir /s /q ' . $target); @@ -177,24 +178,23 @@ class FileSystem * @throws FileSystemException * @throws RuntimeException */ - public static function extractSource(string $name, string $filename, ?string $move_path = null): void + public static function extractSource(string $name, string $source_type, string $filename, ?string $move_path = null): void { // if source hook is empty, load it if (self::$_extract_hook === []) { SourcePatcher::init(); } - if ($move_path !== null) { - $move_path = SOURCE_PATH . '/' . $move_path; - } else { - $move_path = SOURCE_PATH . "/{$name}"; - } + $move_path = match ($move_path) { + null => SOURCE_PATH . '/' . $name, + default => self::isRelativePath($move_path) ? (SOURCE_PATH . '/' . $move_path) : $move_path, + }; $target = self::convertPath($move_path); - logger()->info("extracting {$name} source to {$target}" . ' ...'); + logger()->info("Extracting {$name} source to {$target}" . ' ...'); if (!is_dir($dir = dirname($target))) { self::createDir($dir); } try { - self::extractArchive($filename, $target); + self::extractWithType($source_type, $filename, $move_path); self::emitSourceExtractHook($name, $target); } catch (RuntimeException $e) { if (PHP_OS_FAMILY === 'Windows') { @@ -484,11 +484,6 @@ class FileSystem */ private static function extractArchive(string $filename, string $target): void { - // Git source, just move - if (is_dir(self::convertPath($filename))) { - self::copyDir(self::convertPath($filename), $target); - return; - } // Create base dir if (f_mkdir(directory: $target, recursive: true) !== true) { throw new FileSystemException('create ' . $target . ' dir failed'); @@ -553,4 +548,16 @@ class FileSystem } } } + + private static function extractWithType(string $source_type, string $filename, string $extract_path): void + { + logger()->debug('Extracting source [' . $source_type . ']: ' . $filename); + /* @phpstan-ignore-next-line */ + match ($source_type) { + SPC_SOURCE_ARCHIVE => self::extractArchive($filename, $extract_path), + SPC_SOURCE_GIT => self::copyDir(self::convertPath($filename), $extract_path), + // soft link to the local source + SPC_SOURCE_LOCAL => symlink(self::convertPath($filename), $extract_path), + }; + } } diff --git a/src/SPC/store/PackageManager.php b/src/SPC/store/PackageManager.php index 6ff2faef..ca930228 100644 --- a/src/SPC/store/PackageManager.php +++ b/src/SPC/store/PackageManager.php @@ -34,9 +34,10 @@ class PackageManager Downloader::downloadPackage($pkg_name, $config, $force); // After download, read lock file name $lock = json_decode(FileSystem::readFile(DOWNLOAD_PATH . '/.lock.json'), true); + $source_type = $lock[$pkg_name]['source_type']; $filename = DOWNLOAD_PATH . '/' . ($lock[$pkg_name]['filename'] ?? $lock[$pkg_name]['dirname']); $extract = $lock[$pkg_name]['move_path'] === null ? (PKG_ROOT_PATH . '/' . $pkg_name) : $lock[$pkg_name]['move_path']; - FileSystem::extractPackage($pkg_name, $filename, $extract); + FileSystem::extractPackage($pkg_name, $source_type, $filename, $extract); // if contains extract-files, we just move this file to destination, and remove extract dir if (is_array($config['extract-files'] ?? null) && is_assoc_array($config['extract-files'])) { diff --git a/src/SPC/store/SourceManager.php b/src/SPC/store/SourceManager.php index 4b06df34..dd419e35 100644 --- a/src/SPC/store/SourceManager.php +++ b/src/SPC/store/SourceManager.php @@ -69,10 +69,39 @@ class SourceManager $check = $lock[$lock_name]['move_path'] === null ? (SOURCE_PATH . '/' . $source) : (SOURCE_PATH . '/' . $lock[$lock_name]['move_path']); if (!is_dir($check)) { logger()->debug('Extracting source [' . $source . '] to ' . $check . ' ...'); - FileSystem::extractSource($source, DOWNLOAD_PATH . '/' . ($lock[$lock_name]['filename'] ?? $lock[$lock_name]['dirname']), $lock[$lock_name]['move_path']); - } else { - logger()->debug('Source [' . $source . '] already extracted in ' . $check . ', skip !'); + $filename = self::getSourceFullPath($lock[$lock_name]); + FileSystem::extractSource($source, $lock[$lock_name]['source_type'], $filename, $lock[$lock_name]['move_path']); + Downloader::putLockSourceHash($lock[$lock_name], $check); + continue; } + // if a lock file does not have hash, calculate with the current source (backward compatibility) + if (!isset($lock[$lock_name]['hash'])) { + $hash = Downloader::getLockSourceHash($lock[$lock_name]); + } else { + $hash = $lock[$lock_name]['hash']; + } + + // when source already extracted, detect if the extracted source hash is the same as the lock file one + if (file_exists("{$check}/.spc-hash") && FileSystem::readFile("{$check}/.spc-hash") === $hash) { + logger()->debug('Source [' . $source . '] already extracted in ' . $check . ', skip !'); + continue; + } + + // if not, remove the source dir and extract again + logger()->notice("Source [{$source}] hash mismatch, removing old source dir and extracting again ..."); + FileSystem::removeDir($check); + $filename = self::getSourceFullPath($lock[$lock_name]); + FileSystem::extractSource($source, $lock[$lock_name]['source_type'], $filename, $lock[$lock_name]['move_path']); + Downloader::putLockSourceHash($lock[$lock_name], $check); } } + + private static function getSourceFullPath(array $lock_options): string + { + return match ($lock_options['source_type']) { + SPC_SOURCE_ARCHIVE => FileSystem::isRelativePath($lock_options['filename']) ? (DOWNLOAD_PATH . '/' . $lock_options['filename']) : $lock_options['filename'], + SPC_SOURCE_GIT, SPC_SOURCE_LOCAL => FileSystem::isRelativePath($lock_options['dirname']) ? (DOWNLOAD_PATH . '/' . $lock_options['dirname']) : $lock_options['dirname'], + default => throw new WrongUsageException("Unknown source type: {$lock_options['source_type']}"), + }; + } } diff --git a/src/globals/defines.php b/src/globals/defines.php index f3d290ba..eab2fcc4 100644 --- a/src/globals/defines.php +++ b/src/globals/defines.php @@ -40,7 +40,7 @@ const SPC_EXTENSION_ALIAS = [ 'zendopcache' => 'opcache', ]; -// spc lock type +// spc download lock type const SPC_DOWNLOAD_SOURCE = 1; // lock source const SPC_DOWNLOAD_PRE_BUILT = 2; // lock pre-built const SPC_DOWNLOAD_PACKAGE = 3; // lock as package @@ -84,5 +84,10 @@ const AUTOCONF_CPPFLAGS = 4; const AUTOCONF_LDFLAGS = 8; const AUTOCONF_ALL = 15; +// spc download source type +const SPC_SOURCE_ARCHIVE = 'archive'; // download as archive +const SPC_SOURCE_GIT = 'git'; // download as git repository +const SPC_SOURCE_LOCAL = 'local'; // download as local directory + ConsoleLogger::$date_format = 'H:i:s'; ConsoleLogger::$format = '[%date%] [%level_short%] %body%'; diff --git a/tests/SPC/store/DownloaderTest.php b/tests/SPC/store/DownloaderTest.php index a9507e08..5c15d42e 100644 --- a/tests/SPC/store/DownloaderTest.php +++ b/tests/SPC/store/DownloaderTest.php @@ -57,7 +57,7 @@ class DownloaderTest extends TestCase public function testLockSource() { - Downloader::lockSource('fake-file', ['source_type' => 'archive', 'filename' => 'fake-file-name', 'move_path' => 'fake-path', 'lock_as' => 'fake-lock-as']); + Downloader::lockSource('fake-file', ['source_type' => SPC_SOURCE_ARCHIVE, 'filename' => 'fake-file-name', 'move_path' => 'fake-path', 'lock_as' => 'fake-lock-as']); $this->assertFileExists(DOWNLOAD_PATH . '/.lock.json'); $json = json_decode(file_get_contents(DOWNLOAD_PATH . '/.lock.json'), true); $this->assertIsArray($json); @@ -66,7 +66,7 @@ class DownloaderTest extends TestCase $this->assertArrayHasKey('filename', $json['fake-file']); $this->assertArrayHasKey('move_path', $json['fake-file']); $this->assertArrayHasKey('lock_as', $json['fake-file']); - $this->assertEquals('archive', $json['fake-file']['source_type']); + $this->assertEquals(SPC_SOURCE_ARCHIVE, $json['fake-file']['source_type']); $this->assertEquals('fake-file-name', $json['fake-file']['filename']); $this->assertEquals('fake-path', $json['fake-file']['move_path']); $this->assertEquals('fake-lock-as', $json['fake-file']['lock_as']); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index d573204b..c2fb6eee 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -2,5 +2,7 @@ declare(strict_types=1); +putenv('SPC_IGNORE_BAD_HASH=yes'); + require_once __DIR__ . '/../src/globals/internal-env.php'; require_once __DIR__ . '/mock/SPC_store.php';