From 50ebac20a25951f290db37d6a6055cc3ca2de4ed Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Wed, 8 Jan 2025 13:55:50 -0500 Subject: [PATCH] Add support for publishing .nupkg file to ACR (#1763) --- src/code/ContainerRegistryServerAPICalls.cs | 13 +- src/code/PSResourceInfo.cs | 19 +- src/code/PublishHelper.cs | 212 +++++++++++++++++- src/code/Utils.cs | 73 ++++++ ...SResourceContainerRegistryServer.Tests.ps1 | 39 ++++ .../temp-testmodule-nupkgpath.1.0.0.nupkg | Bin 0 -> 3393 bytes .../temp-testnupkg-nupkgpath.1.0.0.nupkg | Bin 0 -> 2067 bytes .../temp-testscript-nupkgpath.1.0.0.nupkg | Bin 0 -> 2211 bytes 8 files changed, 338 insertions(+), 18 deletions(-) create mode 100644 test/testFiles/testNupkgs/temp-testmodule-nupkgpath.1.0.0.nupkg create mode 100644 test/testFiles/testNupkgs/temp-testnupkg-nupkgpath.1.0.0.nupkg create mode 100644 test/testFiles/testNupkgs/temp-testscript-nupkgpath.1.0.0.nupkg diff --git a/src/code/ContainerRegistryServerAPICalls.cs b/src/code/ContainerRegistryServerAPICalls.cs index d32b2300f..d9175c0b0 100644 --- a/src/code/ContainerRegistryServerAPICalls.cs +++ b/src/code/ContainerRegistryServerAPICalls.cs @@ -649,9 +649,9 @@ internal Hashtable GetContainerRegistryMetadata(string packageName, string exact pkgVersionString += $"-{pkgPrereleaseLabelElement.ToString()}"; } } - else if (rootDom.TryGetProperty("Version", out pkgVersionElement)) + else if (rootDom.TryGetProperty("Version", out pkgVersionElement) || rootDom.TryGetProperty("version", out pkgVersionElement)) { - // script metadata will have "Version" property + // script metadata will have "Version" property, but nupkg only based .nuspec will have lowercase "version" property and JsonElement.TryGetProperty() is case sensitive pkgVersionString = pkgVersionElement.ToString(); } else @@ -1115,12 +1115,11 @@ private static Collection> GetDefaultHeaders(string #endregion #region Publish Methods - /// /// Helper method that publishes a package to the container registry. /// This gets called from Publish-PSResource. /// - internal bool PushNupkgContainerRegistry(string psd1OrPs1File, + internal bool PushNupkgContainerRegistry( string outputNupkgDir, string packageName, string modulePrefix, @@ -1128,10 +1127,14 @@ internal bool PushNupkgContainerRegistry(string psd1OrPs1File, ResourceType resourceType, Hashtable parsedMetadataHash, Hashtable dependencies, + bool isNupkgPathSpecified, + string originalNupkgPath, out ErrorRecord errRecord) { _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::PushNupkgContainerRegistry()"); - string fullNupkgFile = System.IO.Path.Combine(outputNupkgDir, packageName + "." + packageVersion.ToNormalizedString() + ".nupkg"); + + // if isNupkgPathSpecified, then we need to publish the original .nupkg file, as it may be signed + string fullNupkgFile = isNupkgPathSpecified ? originalNupkgPath : System.IO.Path.Combine(outputNupkgDir, packageName + "." + packageVersion.ToNormalizedString() + ".nupkg"); string pkgNameForUpload = string.IsNullOrEmpty(modulePrefix) ? packageName : modulePrefix + "/" + packageName; string packageNameLowercase = pkgNameForUpload.ToLower(); diff --git a/src/code/PSResourceInfo.cs b/src/code/PSResourceInfo.cs index db88bfa8a..409665edc 100644 --- a/src/code/PSResourceInfo.cs +++ b/src/code/PSResourceInfo.cs @@ -836,7 +836,8 @@ public static bool TryConvertFromContainerRegistryJson( // Version // For scripts (i.e with "Version" property) the version can contain prerelease label - if (rootDom.TryGetProperty("Version", out JsonElement scriptVersionElement)) + // For nupkg only based packages the .nuspec's metadata attributes will be lowercase + if (rootDom.TryGetProperty("Version", out JsonElement scriptVersionElement) || rootDom.TryGetProperty("version", out scriptVersionElement)) { versionValue = scriptVersionElement.ToString(); pkgVersion = ParseHttpVersion(versionValue, out string prereleaseLabel); @@ -883,25 +884,25 @@ public static bool TryConvertFromContainerRegistryJson( metadata["NormalizedVersion"] = parsedNormalizedVersion.ToNormalizedString(); // License Url - if (rootDom.TryGetProperty("LicenseUrl", out JsonElement licenseUrlElement)) + if (rootDom.TryGetProperty("LicenseUrl", out JsonElement licenseUrlElement) || rootDom.TryGetProperty("licenseUrl", out licenseUrlElement)) { metadata["LicenseUrl"] = ParseHttpUrl(licenseUrlElement.ToString()) as Uri; } // Project Url - if (rootDom.TryGetProperty("ProjectUrl", out JsonElement projectUrlElement)) + if (rootDom.TryGetProperty("ProjectUrl", out JsonElement projectUrlElement) || rootDom.TryGetProperty("projectUrl", out projectUrlElement)) { metadata["ProjectUrl"] = ParseHttpUrl(projectUrlElement.ToString()) as Uri; } // Icon Url - if (rootDom.TryGetProperty("IconUrl", out JsonElement iconUrlElement)) + if (rootDom.TryGetProperty("IconUrl", out JsonElement iconUrlElement) || rootDom.TryGetProperty("iconUrl", out iconUrlElement)) { metadata["IconUrl"] = ParseHttpUrl(iconUrlElement.ToString()) as Uri; } // Tags - if (rootDom.TryGetProperty("Tags", out JsonElement tagsElement)) + if (rootDom.TryGetProperty("Tags", out JsonElement tagsElement) || rootDom.TryGetProperty("tags", out tagsElement)) { string[] pkgTags = Utils.EmptyStrArray; if (tagsElement.ValueKind == JsonValueKind.Array) @@ -937,7 +938,7 @@ public static bool TryConvertFromContainerRegistryJson( } // Author - if (rootDom.TryGetProperty("Authors", out JsonElement authorsElement)) + if (rootDom.TryGetProperty("Authors", out JsonElement authorsElement) || rootDom.TryGetProperty("authors", out authorsElement)) { metadata["Authors"] = authorsElement.ToString(); @@ -948,19 +949,19 @@ public static bool TryConvertFromContainerRegistryJson( } // Copyright - if (rootDom.TryGetProperty("Copyright", out JsonElement copyrightElement)) + if (rootDom.TryGetProperty("Copyright", out JsonElement copyrightElement) || rootDom.TryGetProperty("copyright", out copyrightElement)) { metadata["Copyright"] = copyrightElement.ToString(); } // Description - if (rootDom.TryGetProperty("Description", out JsonElement descriptiontElement)) + if (rootDom.TryGetProperty("Description", out JsonElement descriptiontElement) || rootDom.TryGetProperty("description", out descriptiontElement)) { metadata["Description"] = descriptiontElement.ToString(); } // ReleaseNotes - if (rootDom.TryGetProperty("ReleaseNotes", out JsonElement releaseNotesElement)) + if (rootDom.TryGetProperty("ReleaseNotes", out JsonElement releaseNotesElement) || rootDom.TryGetProperty("releaseNotes", out releaseNotesElement)) { metadata["ReleaseNotes"] = releaseNotesElement.ToString(); } diff --git a/src/code/PublishHelper.cs b/src/code/PublishHelper.cs index 0eec8e0d9..66daea84e 100644 --- a/src/code/PublishHelper.cs +++ b/src/code/PublishHelper.cs @@ -9,6 +9,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.RegularExpressions; using System.Management.Automation; using System.Net; using System.Net.Http; @@ -440,12 +441,28 @@ internal void PushResource(string Repository, string modulePrefix, bool SkipDepe { ContainerRegistryServerAPICalls containerRegistryServer = new ContainerRegistryServerAPICalls(repository, _cmdletPassedIn, _networkCredential, userAgentString); - var pkgMetadataFile = (resourceType == ResourceType.Script) ? pathToScriptFileToPublish : pathToModuleManifestToPublish; + if (_isNupkgPathSpecified) + { + // copy the .nupkg to a temp path (outputNupkgDir field) as we don't want to tamper with the original, possibly signed, .nupkg file + string copiedNupkgFilePath = CopyNupkgFileToTempPath(nupkgFilePath: Path, errRecord: out ErrorRecord copyErrRecord); + if (copyErrRecord != null) + { + _cmdletPassedIn.WriteError(copyErrRecord); + return; + } + + // get package info (name, version, metadata hashtable) from the copied .nupkg package and then populate appropriate fields (_pkgName, _pkgVersion, parsedMetadata) + GetPackageInfoFromNupkg(nupkgFilePath: copiedNupkgFilePath, errRecord: out ErrorRecord pkgInfoErrRecord); + if (pkgInfoErrRecord != null) + { + _cmdletPassedIn.WriteError(pkgInfoErrRecord); + return; + } + } - if (!containerRegistryServer.PushNupkgContainerRegistry(pkgMetadataFile, outputNupkgDir, _pkgName, modulePrefix, _pkgVersion, resourceType, parsedMetadata, dependencies, out ErrorRecord pushNupkgContainerRegistryError)) + if (!containerRegistryServer.PushNupkgContainerRegistry(outputNupkgDir, _pkgName, modulePrefix, _pkgVersion, resourceType, parsedMetadata, dependencies, _isNupkgPathSpecified, Path, out ErrorRecord pushNupkgContainerRegistryError)) { _cmdletPassedIn.WriteError(pushNupkgContainerRegistryError); - // exit out of processing return; } } @@ -455,6 +472,7 @@ internal void PushResource(string Repository, string modulePrefix, bool SkipDepe { outputNupkgDir = pathToNupkgToPublish; } + // This call does not throw any exceptions, but it will write unsuccessful responses to the console if (!PushNupkg(outputNupkgDir, repository.Name, repository.Uri.ToString(), out ErrorRecord pushNupkgError)) { @@ -474,7 +492,8 @@ internal void PushResource(string Repository, string modulePrefix, bool SkipDepe } finally { - if (!_isNupkgPathSpecified) + // For scenarios such as Publish-PSResource -NupkgPath -Repository , the outputNupkgDir will be set to NupkgPath path, and a temp outputDir folder will not have been created and thus doesn't need to attempt to be deleted + if (Directory.Exists(outputDir)) { _cmdletPassedIn.WriteVerbose(string.Format("Deleting temporary directory '{0}'", outputDir)); Utils.DeleteDirectory(outputDir); @@ -1243,6 +1262,191 @@ private bool CheckDependenciesExist(Hashtable dependencies, string repositoryNam return true; } + /// + /// This method is called by Publish-PSResource when the -NupkgPath parameter is specified + /// The method copies the .nupkg file to a temp path (populated at outputNupkgDir field) as we dont' want to extract and read original .nupkg file + /// + private string CopyNupkgFileToTempPath(string nupkgFilePath, out ErrorRecord errRecord) + { + errRecord = null; + string destinationFilePath = String.Empty; + var packageFullName = System.IO.Path.GetFileName(nupkgFilePath); + try + { + if (!Directory.Exists(outputDir)) + { + Directory.CreateDirectory(outputDir); + if (!Directory.Exists(outputNupkgDir)) + { + Directory.CreateDirectory(outputNupkgDir); + } + } + + destinationFilePath = System.IO.Path.Combine(outputNupkgDir, packageFullName); + File.Copy(Path, destinationFilePath); + } + catch (Exception e) + { + errRecord = new ErrorRecord( + new ArgumentException($"Error moving .nupkg at -NupkgPath to temp nupkg dir path '{outputNupkgDir}' due to: '{e.Message}'."), + "ErrorMovingNupkg", + ErrorCategory.NotSpecified, + this); + + // exit process record + return destinationFilePath; + } + + return destinationFilePath; + } + + /// + /// Get package info from the .nupkg file provided, inluding package name (_pkgName), package version (_pkgVersion), and metadata parsed into a hashtable (parsedMetadata) + /// + private void GetPackageInfoFromNupkg(string nupkgFilePath, out ErrorRecord errRecord) + { + errRecord = null; + Regex rx = new Regex(@"\.\d+\.", RegexOptions.Compiled | RegexOptions.IgnoreCase); + var packageFullName = System.IO.Path.GetFileName(nupkgFilePath); + MatchCollection matches = rx.Matches(packageFullName); + if (matches.Count == 0) + { + return; + } + + Match match = matches[0]; + + GroupCollection groups = match.Groups; + if (groups.Count == 0) + { + return; + } + + Capture group = groups[0]; + + string pkgFoundName = packageFullName.Substring(0, group.Index); + + string version = packageFullName.Substring(group.Index + 1, packageFullName.LastIndexOf('.') - group.Index - 1); + _cmdletPassedIn.WriteDebug($"Found package '{pkgFoundName}', version '{version}', from packageFullName '{packageFullName}' at path '{Path}'"); + + if (!NuGetVersion.TryParse(version, out NuGetVersion nugetVersion)) + { + errRecord = new ErrorRecord( + new ArgumentException($"Error parsing version '{version}' into NuGetVersion instance."), + "ErrorParsingNuGetVersion", + ErrorCategory.NotSpecified, + this); + + return; + } + + _pkgName = pkgFoundName; + _pkgVersion = nugetVersion; + parsedMetadata = GetMetadataFromNupkg(nupkgFilePath, _pkgName, out errRecord); + } + + /// + /// Extract copied .nupkg, find metadata file (either .ps1, .psd1, or .nuspec) and read metadata into a hashtable + /// + internal Hashtable GetMetadataFromNupkg(string copiedNupkgPath, string packageName, out ErrorRecord errRecord) + { + Hashtable pkgMetadata = new Hashtable(StringComparer.OrdinalIgnoreCase); + errRecord = null; + + // in temp directory create an "extract" folder to which we'll copy .nupkg to, extract contents, etc. + string nupkgDirPath = Directory.GetParent(copiedNupkgPath).FullName; //someGuid/nupkg/myPkg.nupkg -> /someGuid/nupkg + string tempPath = Directory.GetParent(nupkgDirPath).FullName; // someGuid + var extractPath = System.IO.Path.Combine(tempPath, "extract"); // someGuid/extract + + try + { + var dir = Directory.CreateDirectory(extractPath); + dir.Attributes &= ~FileAttributes.ReadOnly; + + // change extension to .zip + string zipFilePath = System.IO.Path.ChangeExtension(copiedNupkgPath, ".zip"); + File.Move(copiedNupkgPath, zipFilePath); + + // extract from .zip + _cmdletPassedIn.WriteDebug($"Extracting '{zipFilePath}' to '{extractPath}'"); + System.IO.Compression.ZipFile.ExtractToDirectory(zipFilePath, extractPath); + + string psd1FilePath = String.Empty; + string ps1FilePath = String.Empty; + string nuspecFilePath = String.Empty; + Utils.GetMetadataFilesFromPath(extractPath, packageName, out psd1FilePath, out ps1FilePath, out nuspecFilePath, out string properCasingPkgName); + + List pkgTags = new List(); + + if (File.Exists(psd1FilePath)) + { + _cmdletPassedIn.WriteDebug($"Attempting to read module manifest file '{psd1FilePath}'"); + if (!Utils.TryReadManifestFile(psd1FilePath, out pkgMetadata, out Exception readManifestError)) + { + errRecord = new ErrorRecord( + readManifestError, + "GetMetadataFromNupkgFailure", + ErrorCategory.ParserError, + this); + + return pkgMetadata; + } + } + else if (File.Exists(ps1FilePath)) + { + _cmdletPassedIn.WriteDebug($"Attempting to read script file '{ps1FilePath}'"); + if (!PSScriptFileInfo.TryTestPSScriptFileInfo(ps1FilePath, out PSScriptFileInfo parsedScript, out ErrorRecord[] errors, out string[] verboseMsgs)) + { + errRecord = new ErrorRecord( + new InvalidDataException($"PSScriptFile could not be read properly"), + "GetMetadataFromNupkgFailure", + ErrorCategory.ParserError, + this); + + return pkgMetadata; + } + + pkgMetadata = parsedScript.ToHashtable(); + } + else if (File.Exists(nuspecFilePath)) + { + _cmdletPassedIn.WriteDebug($"Attempting to read nuspec file '{nuspecFilePath}'"); + pkgMetadata = Utils.GetMetadataFromNuspec(nuspecFilePath, _cmdletPassedIn, out errRecord); + if (errRecord != null) + { + return pkgMetadata; + } + } + else + { + errRecord = new ErrorRecord( + new InvalidDataException($".nupkg package must contain either .psd1, .ps1, or .nuspec file and none were found"), + "GetMetadataFromNupkgFailure", + ErrorCategory.InvalidData, + this); + + return pkgMetadata; + } + } + catch (Exception e) + { + errRecord = new ErrorRecord( + new InvalidOperationException($"Temporary folder for installation could not be created or set due to: {e.Message}"), + "GetMetadataFromNupkgFailure", + ErrorCategory.InvalidOperation, + this); + } + finally + { + if (Directory.Exists(extractPath)) + { + Utils.DeleteDirectory(extractPath); + } + } + + return pkgMetadata; + } + #endregion } } diff --git a/src/code/Utils.cs b/src/code/Utils.cs index da80d3f42..d51ba0fdb 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -23,6 +23,7 @@ using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using System.Xml; namespace Microsoft.PowerShell.PSResourceGet.UtilClasses { @@ -1583,6 +1584,11 @@ public static void DeleteDirectoryWithRestore(string dirPath) /// public static void DeleteDirectory(string dirPath) { + if (!Directory.Exists(dirPath)) + { + throw new Exception($"Path '{dirPath}' that was attempting to be deleted does not exist."); + } + // Remove read only file attributes first foreach (var dirFilePath in Directory.GetFiles(dirPath,"*",SearchOption.AllDirectories)) { @@ -1830,6 +1836,73 @@ public static void CreateFile(string filePath) #endregion + #region Nuspec file parsing methods + + public static Hashtable GetMetadataFromNuspec(string nuspecFilePath, PSCmdlet cmdletPassedIn, out ErrorRecord errorRecord) + { + Hashtable nuspecHashtable = new Hashtable(StringComparer.InvariantCultureIgnoreCase); + + XmlDocument nuspecXmlDocument = LoadXmlDocument(nuspecFilePath, cmdletPassedIn, out errorRecord); + if (errorRecord != null) + { + return nuspecHashtable; + } + + try + { + XmlNodeList elemList = nuspecXmlDocument.GetElementsByTagName("metadata"); + for(int i = 0; i < elemList.Count; i++) + { + XmlNode metadataInnerXml = elemList[i]; + + for(int j= 0; j + /// Method that loads file content into XMLDocument. Used when reading .nuspec file. + /// + public static XmlDocument LoadXmlDocument(string filePath, PSCmdlet cmdletPassedIn, out ErrorRecord errRecord) + { + errRecord = null; + XmlDocument doc = new XmlDocument(); + doc.PreserveWhitespace = true; + try { doc.Load(filePath); } + catch (Exception e) + { + errRecord = new ErrorRecord( + exception: e, + "LoadXmlDocumentFailure", + ErrorCategory.ReadError, + cmdletPassedIn); + } + + return doc; + } + + #endregion + } #endregion diff --git a/test/PublishPSResourceTests/PublishPSResourceContainerRegistryServer.Tests.ps1 b/test/PublishPSResourceTests/PublishPSResourceContainerRegistryServer.Tests.ps1 index 1426efe11..af57385a1 100644 --- a/test/PublishPSResourceTests/PublishPSResourceContainerRegistryServer.Tests.ps1 +++ b/test/PublishPSResourceTests/PublishPSResourceContainerRegistryServer.Tests.ps1 @@ -97,6 +97,9 @@ Describe "Test Publish-PSResource" -tags 'CI' { # Path to specifically to that invalid test scripts folder $script:testScriptsFolderPath = Join-Path $script:testFilesFolderPath -ChildPath "testScripts" + + # Path to specifically to that invalid test nupkgs folder + $script:testNupkgsFolderPath = Join-Path $script:testFilesFolderPath -ChildPath "testNupkgs" } AfterEach { if(!(Test-Path $script:PublishModuleBase)) @@ -511,6 +514,42 @@ Describe "Test Publish-PSResource" -tags 'CI' { $results[0].Name | Should -Be $script:PublishModuleName $results[0].Version | Should -Be $version } + + It "Publish a package given NupkgPath to a package with .psd1" { + $packageName = "temp-testmodule-nupkgpath" + $version = "1.0.0.0" + $nupkgPath = Join-Path -Path $script:testNupkgsFolderPath -ChildPath "$packageName.1.0.0.nupkg" + Publish-PSResource -NupkgPath $nupkgPath -Repository $ACRRepoName + + $results = Find-PSResource -Name $packageName -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $packageName + $results[0].Version | Should -Be $version + } + + It "Publish a package given NupkgPath to a package with .ps1" { + $packageName = "temp-testscript-nupkgpath" + $version = "1.0.0.0" + $nupkgPath = Join-Path -Path $script:testNupkgsFolderPath -ChildPath "$packageName.1.0.0.nupkg" + Publish-PSResource -NupkgPath $nupkgPath -Repository $ACRRepoName + + $results = Find-PSResource -Name $packageName -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $packageName + $results[0].Version | Should -Be $version + } + + It "Publish a package given NupkgPath to a package with .nuspec" { + $packageName = "temp-testnupkg-nupkgpath" + $version = "1.0.0" + $nupkgPath = Join-Path -Path $script:testNupkgsFolderPath -ChildPath "$packageName.1.0.0.nupkg" + Publish-PSResource -NupkgPath $nupkgPath -Repository $ACRRepoName + + $results = Find-PSResource -Name $packageName -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $packageName + $results[0].Version | Should -Be $version + } } Describe 'Test Publish-PSResource for MAR Repository' -tags 'CI' { diff --git a/test/testFiles/testNupkgs/temp-testmodule-nupkgpath.1.0.0.nupkg b/test/testFiles/testNupkgs/temp-testmodule-nupkgpath.1.0.0.nupkg new file mode 100644 index 0000000000000000000000000000000000000000..a0f7d11d86d7c2aab04394d18fa26ff462815058 GIT binary patch literal 3393 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 literal 0 HcmV?d00001 diff --git a/test/testFiles/testNupkgs/temp-testscript-nupkgpath.1.0.0.nupkg b/test/testFiles/testNupkgs/temp-testscript-nupkgpath.1.0.0.nupkg new file mode 100644 index 0000000000000000000000000000000000000000..e3adbf814133c78523a2e2e62abfe9b6ded238a1 GIT binary patch literal 2211 zcmcJQXIN8N8plIPz=%?U^fH1VD-ePhs!pUyT@6STgb5*(03jwJ;0Wp%1Qn!&Cc;q0 zGLQv_j+C$jL`pycNEeb377zs#qzl>Wepr>A+5NQlJonz`p7WmndG7Cd-~Wqt5E7OK zfk2|5gMsOubM3Ds)CE8w5kU}WKQQY{!iAFc;GduAsXh2sghcGp%_bUWA~Uo-$@O*Y zy(&&f$Nr+_F`3&}Qw$4ZwxRbeEdEl}{`17u>i9;gf|sb5o$8h>kxHkD!G5rjzwtp{QE_cpAHaR+qqbKcA&Q|C1i%Ut_68O_`GI9YK)gj0!3E9MF{`7>#_t=wE z$&q8(6Ljr|rnI5Ebazj0xzRW)g~s@_R=3W#f8BQ^TJd6wY#;|lNMv>J4$KREjJ4l= zo3*a%!+V?!V?4EGnLqnb9qHfJfYq*nZ4(0i9<%Jwr!GOTe8L&vD0-@CK1-f9WsmaB zWYY@Ond3{dJ{o~0;jWV-vX#!1?VdOSGq*9eppIh2n`|e zoD1g1U+8uE5~N7Pwr6u>3COtM!KM?~}&C&Tj`I$CFF` zrq?ukB|%85N7#mKP0?=ezu_dU;ne7<;S`khYuM8#^I;xtImX@@m*h357l#e0<4+M+ z3R4zLw+HgH+i!Jf%-AH6PaCAzwDmKyAF_(`>Jue-3eYx9-Ha%(pUQnnma5?70j;D; zZ;qM}RLU59R<0WZorFHVj{oJ}34_KQ&5iDKfuZJ`0x?31qSR)HmSL0{D`9Ct(C7gI zyo2glf4+kFg%u-xSN5eZ`!{M3C)~{NmhX12cyRlS%6pM-Q*t(KgY*)B)dxU>0dFtM zZ~_HKp!m96CgR8!;Zb3sxvA#(*3)9v>o+GcoSKm-ij#eLnstuiM?sp-xaESpKHZYE zeaz1NW*p~H6rW#M0)IcE?8x{u z!{k{xq?j6Ls9VAv&}Qs86P~+VC(5WNp*=U9?)Kv($cy}Br{Hi&W&KVc$JHlDhPX8R zestLUhdgx%a#6gPk7HcbsYdF_XREQ(z|ET|q>e-KCzRxj#Ua@T*6I_5W3S%(yYzdl zRzYEtwVW+>d<_@)#s93!6!89+MF0RM;Bihs1QCM`!35&;$T$);7>gt8h2ba|e+&hq zhYcs;bcv*JB924}1{N?Fg#K~;;|K#J4s!w-;BR7rL>T)6vwldE<47YMoJbDy#}Y%G zSxGqv38woz$|IL{0Fyfo6O06 z{U&0rbHB&0iSP&Mnvw##i7s0nXJCdOEI+z1uepn23u^*|6QD{TbQTPq)14BG337@@ z$IoMv9ifm@Vg-HW>5p|Tl!;P_e2v%_H=-l(<~=KpvpT#XJ9-6oT=hy{OEdP7GD(-$ zazPGWrM&7^y>#OFL8Xgmxw^Rx$rtt}ilDmbj!_fOlkh&Z{LB=KJ9o>P`$q1B^!Q45 z+?a^=F_+3M!&{-6psd4}wf89;KikVB>1H3Q@i)3VQ9d&Q$?zI-wX;JR3~H761j$WR zjwv1+3S5rT;DUtKE0|q+$(rDX%)((-u%TN$V{(b+#>)H+G*{PJ>RX$YmVb7Tum!Pw zi_p3zsJ*;GQZU)EJ%7KnW45@x)1nw-!QMVp+^gRfX{_uhQs3P_t9j*VZ(aAYS;wMk zt-Z&TBW*gXzrQ=_-N7TPZf{J_!T8_zPh<&g;x|E)9yW_}BK7bQ)Qe9C;#_x5waknE zJ1qnRr9pq*{D82puCH1l+Tq)cd!7EvE=M5G{+I86t@cOG_C<{WWbB`-0Y3M&)aQ1- qx8A=