Skip to content

Commit fb9e927

Browse files
author
Eugene Bochilo
committed
Add tests for advanced signing operations
DEVSIX-7773
1 parent 6116845 commit fb9e927

File tree

50 files changed

+1444
-6
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+1444
-6
lines changed

sharpenConfiguration.xml

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -479,7 +479,7 @@
479479
<file path="com/itextpdf/signatures/sign/IsoSignatureExtensionsRoundtripTest.java"/>
480480
<file path="com/itextpdf/signatures/sign/PdfPadesSignerLevelsTest.java"/>
481481
<file path="com/itextpdf/signatures/sign/LtvSigTest.java"/>
482-
<file path="com/itextpdf/signatures/sign/PdfPadesSignerLevelsTest.java"/>
482+
<file path="com/itextpdf/signatures/sign/PdfPadesAdvancedTest.java"/>
483483
<file path="com/itextpdf/signatures/sign/PadesSignatureLevelTest.java"/>
484484
<file path="com/itextpdf/signatures/sign/TimestampSigTest.java"/>
485485
</fileset>
@@ -585,6 +585,7 @@
585585
<file path="com/itextpdf/signatures/sign/PadesSignatureLevelTest/helloWorldDoc.pdf"/>
586586
<file path="com/itextpdf/signatures/sign/PadesSignatureLevelTest/signedPAdES-LT.pdf"/>
587587
<file path="com/itextpdf/signatures/sign/PadesSignatureLevelTest/signedPAdES-T.pdf"/>
588+
588589
<file path="com/itextpdf/signatures/sign/PdfPadesSignerLevelsTest/cmp_padesSignatureLevelBTest1.pdf"/>
589590
<file path="com/itextpdf/signatures/sign/PdfPadesSignerLevelsTest/cmp_padesSignatureLevelBTest2.pdf"/>
590591
<file path="com/itextpdf/signatures/sign/PdfPadesSignerLevelsTest/cmp_padesSignatureLevelBTest3.pdf"/>
@@ -603,6 +604,37 @@
603604
<file path="com/itextpdf/signatures/sign/PdfPadesSignerLevelsTest/cmp_prolongDocumentSignaturesTest1_FIPS.pdf"/>
604605
<file path="com/itextpdf/signatures/sign/PdfPadesSignerLevelsTest/cmp_prolongDocumentSignaturesTest2_FIPS.pdf"/>
605606
<file path="com/itextpdf/signatures/sign/PdfPadesSignerLevelsTest/cmp_prolongDocumentSignaturesTest3_FIPS.pdf"/>
607+
608+
<file path="com/itextpdf/signatures/sign/PdfPadesAdvancedTest/cmp_signedWith_signCertCrlNoOcsp_rootCertCrlNoOcsp.pdf"/>
609+
<file path="com/itextpdf/signatures/sign/PdfPadesAdvancedTest/cmp_signedWith_signCertCrlNoOcsp_rootCertCrlOcsp.pdf"/>
610+
<file path="com/itextpdf/signatures/sign/PdfPadesAdvancedTest/cmp_signedWith_signCertCrlNoOcsp_rootCertOcspNoCrl.pdf"/>
611+
<file path="com/itextpdf/signatures/sign/PdfPadesAdvancedTest/cmp_signedWith_signCertCrlNoOcsp_rootCertNoCrlNoOcsp.pdf"/>
612+
613+
<file path="com/itextpdf/signatures/sign/PdfPadesAdvancedTest/cmp_signedWith_signCertCrlOcsp_rootCertCrlNoOcsp.pdf"/>
614+
<file path="com/itextpdf/signatures/sign/PdfPadesAdvancedTest/cmp_signedWith_signCertCrlOcsp_rootCertCrlOcsp.pdf"/>
615+
<file path="com/itextpdf/signatures/sign/PdfPadesAdvancedTest/cmp_signedWith_signCertCrlOcsp_rootCertOcspNoCrl.pdf"/>
616+
<file path="com/itextpdf/signatures/sign/PdfPadesAdvancedTest/cmp_signedWith_signCertCrlOcsp_rootCertNoCrlNoOcsp.pdf"/>
617+
618+
<file path="com/itextpdf/signatures/sign/PdfPadesAdvancedTest/cmp_signedWith_signCertCrlOcsp_rootCertCrlNoOcsp_revoked.pdf"/>
619+
<file path="com/itextpdf/signatures/sign/PdfPadesAdvancedTest/cmp_signedWith_signCertCrlOcsp_rootCertCrlOcsp_revoked.pdf"/>
620+
<file path="com/itextpdf/signatures/sign/PdfPadesAdvancedTest/cmp_signedWith_signCertCrlOcsp_rootCertOcspNoCrl_revoked.pdf"/>
621+
<file path="com/itextpdf/signatures/sign/PdfPadesAdvancedTest/cmp_signedWith_signCertCrlOcsp_rootCertNoCrlNoOcsp_revoked.pdf"/>
622+
623+
<file path="com/itextpdf/signatures/sign/PdfPadesAdvancedTest/cmp_signedWith_signCertNoOcspNoCrl_rootCertCrlNoOcsp.pdf"/>
624+
<file path="com/itextpdf/signatures/sign/PdfPadesAdvancedTest/cmp_signedWith_signCertNoOcspNoCrl_rootCertCrlOcsp.pdf"/>
625+
<file path="com/itextpdf/signatures/sign/PdfPadesAdvancedTest/cmp_signedWith_signCertNoOcspNoCrl_rootCertOcspNoCrl.pdf"/>
626+
<file path="com/itextpdf/signatures/sign/PdfPadesAdvancedTest/cmp_signedWith_signCertNoOcspNoCrl_rootCertNoCrlNoOcsp.pdf"/>
627+
628+
<file path="com/itextpdf/signatures/sign/PdfPadesAdvancedTest/cmp_signedWith_signCertOcspNoCrl_rootCertCrlNoOcsp.pdf"/>
629+
<file path="com/itextpdf/signatures/sign/PdfPadesAdvancedTest/cmp_signedWith_signCertOcspNoCrl_rootCertCrlOcsp.pdf"/>
630+
<file path="com/itextpdf/signatures/sign/PdfPadesAdvancedTest/cmp_signedWith_signCertOcspNoCrl_rootCertOcspNoCrl.pdf"/>
631+
<file path="com/itextpdf/signatures/sign/PdfPadesAdvancedTest/cmp_signedWith_signCertOcspNoCrl_rootCertNoCrlNoOcsp.pdf"/>
632+
633+
<file path="com/itextpdf/signatures/sign/PdfPadesAdvancedTest/cmp_signedWith_signCertOcspNoCrl_rootCertCrlNoOcsp_revoked.pdf"/>
634+
<file path="com/itextpdf/signatures/sign/PdfPadesAdvancedTest/cmp_signedWith_signCertOcspNoCrl_rootCertCrlOcsp_revoked.pdf"/>
635+
<file path="com/itextpdf/signatures/sign/PdfPadesAdvancedTest/cmp_signedWith_signCertOcspNoCrl_rootCertOcspNoCrl_revoked.pdf"/>
636+
<file path="com/itextpdf/signatures/sign/PdfPadesAdvancedTest/cmp_signedWith_signCertOcspNoCrl_rootCertNoCrlNoOcsp_revoked.pdf"/>
637+
606638
<file path="com/itextpdf/signatures/sign/TimestampSigTest/cmp_timestampTest01.pdf"/>
607639
<file path="com/itextpdf/signatures/PKCS7ExternalSignatureContainerTest/cmp_testTroughPdfSignerWithTsaClient.pdf"/>
608640
<file path="com/itextpdf/signatures/PKCS7ExternalSignatureContainerTest/cmp_testTroughPdfSignerWithTsaClient_FIPS.pdf"/>

sign/src/main/java/com/itextpdf/signatures/CrlClientOnline.java

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ This file is part of the iText (R) project.
2828
import com.itextpdf.commons.utils.MessageFormatUtil;
2929

3030
import java.io.ByteArrayOutputStream;
31+
import java.io.IOException;
3132
import java.io.InputStream;
3233
import java.net.MalformedURLException;
3334
import java.net.URL;
@@ -139,7 +140,7 @@ public Collection<byte[]> getEncoded(X509Certificate checkCert, String url) thro
139140
for (URL urlt : urlList) {
140141
try {
141142
LOGGER.info("Checking CRL: " + urlt);
142-
InputStream inp = SignUtils.getHttpResponse(urlt);
143+
InputStream inp = getCrlResponse(checkCert, urlt);
143144
byte[] buf = new byte[1024];
144145
ByteArrayOutputStream bout = new ByteArrayOutputStream();
145146
while (true) {
@@ -160,6 +161,20 @@ public Collection<byte[]> getEncoded(X509Certificate checkCert, String url) thro
160161
return ar;
161162
}
162163

164+
/**
165+
* Get CRL response represented as {@link InputStream}.
166+
*
167+
* @param cert {@link X509Certificate} certificate to get CRL response for
168+
* @param urlt {@link URL} link, which is expected to be used to get CRL response from
169+
*
170+
* @return CRL response bytes, represented as {@link InputStream}
171+
*
172+
* @throws IOException if an I/O error occurs
173+
*/
174+
protected InputStream getCrlResponse(X509Certificate cert, URL urlt) throws IOException {
175+
return SignUtils.getHttpResponse(urlt);
176+
}
177+
163178
/**
164179
* Adds an URL to the list of CRL URLs
165180
*

sign/src/main/java/com/itextpdf/signatures/OcspClientBouncyCastle.java

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,10 @@ public byte[] getEncoded(X509Certificate checkCert, X509Certificate rootCert, St
140140
*
141141
* @throws AbstractOCSPException is thrown if any errors occur while handling OCSP requests/responses
142142
* @throws IOException signals that an I/O exception has occurred
143+
* @throws CertificateEncodingException is thrown if any errors occur while handling OCSP requests/responses
144+
* @throws AbstractOperatorCreationException is thrown if any errors occur while handling OCSP requests/responses
143145
*/
144-
private static IOCSPReq generateOCSPRequest(X509Certificate issuerCert, BigInteger serialNumber)
146+
protected static IOCSPReq generateOCSPRequest(X509Certificate issuerCert, BigInteger serialNumber)
145147
throws AbstractOCSPException, IOException, CertificateEncodingException, AbstractOperatorCreationException {
146148
//Add provider BC
147149
Security.addProvider(BOUNCY_CASTLE_FACTORY.getProvider());
@@ -180,11 +182,30 @@ IOCSPResp getOcspResponse(X509Certificate checkCert, X509Certificate rootCert, S
180182
if (url == null) {
181183
return null;
182184
}
185+
InputStream in = createRequestAndResponse(checkCert, rootCert, url);
186+
return BOUNCY_CASTLE_FACTORY.createOCSPResp(StreamUtil.inputStreamToArray(in));
187+
}
188+
189+
/**
190+
* Create OCSP request and get the response for this request, represented as {@link InputStream}.
191+
*
192+
* @param checkCert {@link X509Certificate} certificate to get OCSP response for
193+
* @param rootCert {@link X509Certificate} root certificate from which OCSP request will be built
194+
* @param url {@link URL} link, which is expected to be used to get OCSP response from
195+
*
196+
* @return OCSP response bytes, represented as {@link InputStream}
197+
*
198+
* @throws IOException if an I/O error occurs
199+
* @throws AbstractOperatorCreationException is thrown if any errors occur while handling OCSP requests/responses
200+
* @throws AbstractOCSPException is thrown if any errors occur while handling OCSP requests/responses
201+
* @throws CertificateEncodingException is thrown if any errors occur while handling OCSP requests/responses
202+
*/
203+
protected InputStream createRequestAndResponse(X509Certificate checkCert, X509Certificate rootCert, String url)
204+
throws IOException, AbstractOperatorCreationException, AbstractOCSPException, CertificateEncodingException {
183205
LOGGER.info("Getting OCSP from " + url);
184206
IOCSPReq request = generateOCSPRequest(rootCert, checkCert.getSerialNumber());
185207
byte[] array = request.getEncoded();
186208
URL urlt = new URL(url);
187-
InputStream in = SignUtils.getHttpResponseForOcspRequest(array, urlt);
188-
return BOUNCY_CASTLE_FACTORY.createOCSPResp(StreamUtil.inputStreamToArray(in));
209+
return SignUtils.getHttpResponseForOcspRequest(array, urlt);
189210
}
190211
}
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
/*
2+
This file is part of the iText (R) project.
3+
Copyright (c) 1998-2023 Apryse Group NV
4+
Authors: Apryse Software.
5+
6+
This program is offered under a commercial and under the AGPL license.
7+
For commercial licensing, contact us at https://itextpdf.com/sales. For AGPL licensing, see below.
8+
9+
AGPL licensing:
10+
This program is free software: you can redistribute it and/or modify
11+
it under the terms of the GNU Affero General Public License as published by
12+
the Free Software Foundation, either version 3 of the License, or
13+
(at your option) any later version.
14+
15+
This program is distributed in the hope that it will be useful,
16+
but WITHOUT ANY WARRANTY; without even the implied warranty of
17+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18+
GNU Affero General Public License for more details.
19+
20+
You should have received a copy of the GNU Affero General Public License
21+
along with this program. If not, see <https://www.gnu.org/licenses/>.
22+
*/
23+
package com.itextpdf.signatures.sign;
24+
25+
import com.itextpdf.bouncycastleconnector.BouncyCastleFactoryCreator;
26+
import com.itextpdf.commons.bouncycastle.IBouncyCastleFactory;
27+
import com.itextpdf.commons.bouncycastle.operator.AbstractOperatorCreationException;
28+
import com.itextpdf.commons.bouncycastle.pkcs.AbstractPKCSException;
29+
import com.itextpdf.commons.utils.FileUtil;
30+
import com.itextpdf.io.logs.IoLogMessageConstant;
31+
import com.itextpdf.kernel.geom.Rectangle;
32+
import com.itextpdf.kernel.pdf.PdfReader;
33+
import com.itextpdf.kernel.pdf.StampingProperties;
34+
import com.itextpdf.signatures.DigestAlgorithms;
35+
import com.itextpdf.signatures.IExternalSignature;
36+
import com.itextpdf.signatures.PdfPadesSigner;
37+
import com.itextpdf.signatures.PdfSigner;
38+
import com.itextpdf.signatures.PrivateKeySignature;
39+
import com.itextpdf.signatures.testutils.PemFileHelper;
40+
import com.itextpdf.signatures.testutils.SignaturesCompareTool;
41+
import com.itextpdf.signatures.testutils.TimeTestUtil;
42+
import com.itextpdf.signatures.testutils.builder.TestCrlBuilder;
43+
import com.itextpdf.signatures.testutils.builder.TestOcspResponseBuilder;
44+
import com.itextpdf.signatures.testutils.client.AdvancedTestCrlClient;
45+
import com.itextpdf.signatures.testutils.client.AdvancedTestOcspClient;
46+
import com.itextpdf.signatures.testutils.client.TestTsaClient;
47+
import com.itextpdf.test.ExtendedITextTest;
48+
import com.itextpdf.test.annotations.LogMessage;
49+
import com.itextpdf.test.annotations.LogMessages;
50+
import com.itextpdf.test.annotations.type.BouncyCastleIntegrationTest;
51+
52+
import java.io.IOException;
53+
import java.security.GeneralSecurityException;
54+
import java.security.PrivateKey;
55+
import java.security.Security;
56+
import java.security.cert.Certificate;
57+
import java.security.cert.X509Certificate;
58+
import java.util.ArrayList;
59+
import java.util.Arrays;
60+
import java.util.List;
61+
import org.junit.Assert;
62+
import org.junit.BeforeClass;
63+
import org.junit.Test;
64+
import org.junit.experimental.categories.Category;
65+
import org.junit.runner.RunWith;
66+
import org.junit.runners.Parameterized;
67+
68+
@RunWith(Parameterized.class)
69+
@Category(BouncyCastleIntegrationTest.class)
70+
public class PdfPadesAdvancedTest extends ExtendedITextTest {
71+
private static final IBouncyCastleFactory FACTORY = BouncyCastleFactoryCreator.getFactory();
72+
private static final String CERTS_SRC = "./src/test/resources/com/itextpdf/signatures/sign/PdfPadesAdvancedTest/certs/";
73+
private static final String SOURCE_FOLDER = "./src/test/resources/com/itextpdf/signatures/sign/PdfPadesAdvancedTest/";
74+
private static final String DESTINATION_FOLDER = "./target/test/com/itextpdf/signatures/sign/PdfPadesAdvancedTest/";
75+
76+
private static final char[] PASSWORD = "testpassphrase".toCharArray();
77+
78+
private final String signingCertName;
79+
private final String rootCertName;
80+
private final Boolean isOcspRevoked;
81+
private final String cmpFilePostfix;
82+
83+
@BeforeClass
84+
public static void before() {
85+
Security.addProvider(FACTORY.getProvider());
86+
createOrClearDestinationFolder(DESTINATION_FOLDER);
87+
}
88+
89+
public PdfPadesAdvancedTest(Object signingCertName, Object rootCertName, Object isOcspRevoked, Object cmpFilePostfix) {
90+
this.signingCertName = (String) signingCertName;
91+
this.rootCertName = (String) rootCertName;
92+
this.isOcspRevoked = (Boolean) isOcspRevoked;
93+
this.cmpFilePostfix = (String) cmpFilePostfix;
94+
}
95+
96+
@Parameterized.Parameters(name = "{3}: signing cert: {0}; root cert: {1}; revoked: {2}")
97+
public static Iterable<Object[]> createParameters() {
98+
List<Object[]> parameters = new ArrayList<>();
99+
parameters.addAll(createParametersUsingRootName("rootCertNoCrlNoOcsp"));
100+
parameters.addAll(createParametersUsingRootName("rootCertCrlOcsp"));
101+
parameters.addAll(createParametersUsingRootName("rootCertCrlNoOcsp"));
102+
parameters.addAll(createParametersUsingRootName("rootCertOcspNoCrl"));
103+
return parameters;
104+
}
105+
106+
private static List<Object[]> createParametersUsingRootName(String rootCertName) {
107+
return Arrays.asList(
108+
new Object[] {"signCertCrlOcsp.pem", rootCertName + ".pem", false, "_signCertCrlOcsp_" + rootCertName},
109+
new Object[] {"signCertCrlOcsp.pem", rootCertName + ".pem", true, "_signCertCrlOcsp_" + rootCertName + "_revoked"},
110+
new Object[] {"signCertOcspNoCrl.pem", rootCertName + ".pem", false, "_signCertOcspNoCrl_" + rootCertName},
111+
new Object[] {"signCertOcspNoCrl.pem", rootCertName + ".pem", true, "_signCertOcspNoCrl_" + rootCertName + "_revoked"},
112+
new Object[] {"signCertNoOcspNoCrl.pem", rootCertName + ".pem", false, "_signCertNoOcspNoCrl_" + rootCertName},
113+
new Object[] {"signCertCrlNoOcsp.pem", rootCertName + ".pem", false, "_signCertCrlNoOcsp_" + rootCertName}
114+
);
115+
}
116+
117+
@Test
118+
@LogMessages(messages = @LogMessage(messageTemplate = IoLogMessageConstant.OCSP_STATUS_IS_REVOKED), ignore = true)
119+
public void signWithAdvancedClientsTest()
120+
throws IOException, GeneralSecurityException, AbstractOperatorCreationException, AbstractPKCSException {
121+
String fileName = "signedWith" + cmpFilePostfix + ".pdf";
122+
String outFileName = DESTINATION_FOLDER + fileName;
123+
String cmpFileName = SOURCE_FOLDER + "cmp_" + fileName;
124+
String srcFileName = SOURCE_FOLDER + "helloWorldDoc.pdf";
125+
String signCertFileName = CERTS_SRC + signingCertName;
126+
String rootCertFileName = CERTS_SRC + rootCertName;
127+
String tsaCertFileName = CERTS_SRC + "tsCertRsa.pem";
128+
129+
Certificate signRsaCert = PemFileHelper.readFirstChain(signCertFileName)[0];
130+
Certificate rootCert = PemFileHelper.readFirstChain(rootCertFileName)[0];
131+
Certificate[] signRsaChain = new Certificate[2];
132+
signRsaChain[0] = signRsaCert;
133+
signRsaChain[1] = rootCert;
134+
135+
PrivateKey signRsaPrivateKey = PemFileHelper.readFirstKey(signCertFileName, PASSWORD);
136+
PrivateKey rootPrivateKey = PemFileHelper.readFirstKey(rootCertFileName, PASSWORD);
137+
Certificate[] tsaChain = PemFileHelper.readFirstChain(tsaCertFileName);
138+
PrivateKey tsaPrivateKey = PemFileHelper.readFirstKey(tsaCertFileName, PASSWORD);
139+
140+
TestTsaClient testTsa = new TestTsaClient(Arrays.asList(tsaChain), tsaPrivateKey);
141+
142+
AdvancedTestOcspClient testOcspClient = new AdvancedTestOcspClient(null);
143+
TestOcspResponseBuilder ocspBuilderMainCert = new TestOcspResponseBuilder((X509Certificate) signRsaChain[1], rootPrivateKey);
144+
if ((boolean) isOcspRevoked) {
145+
ocspBuilderMainCert.setCertificateStatus(FACTORY.createRevokedStatus(TimeTestUtil.TEST_DATE_TIME,
146+
FACTORY.createCRLReason().getKeyCompromise()));
147+
}
148+
TestOcspResponseBuilder ocspBuilderRootCert = new TestOcspResponseBuilder((X509Certificate) signRsaChain[1], rootPrivateKey);
149+
testOcspClient.addBuilderForCertIssuer((X509Certificate) signRsaChain[0], ocspBuilderMainCert);
150+
testOcspClient.addBuilderForCertIssuer((X509Certificate) signRsaChain[1], ocspBuilderRootCert);
151+
152+
AdvancedTestCrlClient testCrlClient = new AdvancedTestCrlClient();
153+
TestCrlBuilder crlBuilderMainCert = new TestCrlBuilder((X509Certificate) signRsaChain[1], rootPrivateKey);
154+
crlBuilderMainCert.addCrlEntry((X509Certificate) signRsaChain[0], FACTORY.createCRLReason().getKeyCompromise());
155+
crlBuilderMainCert.addCrlEntry((X509Certificate) signRsaChain[1], FACTORY.createCRLReason().getKeyCompromise());
156+
157+
TestCrlBuilder crlBuilderRootCert = new TestCrlBuilder((X509Certificate) signRsaChain[1], rootPrivateKey);
158+
crlBuilderRootCert.addCrlEntry((X509Certificate) signRsaChain[1], FACTORY.createCRLReason().getKeyCompromise());
159+
testCrlClient.addBuilderForCertIssuer((X509Certificate) signRsaChain[0], crlBuilderMainCert);
160+
testCrlClient.addBuilderForCertIssuer((X509Certificate) signRsaChain[1], crlBuilderRootCert);
161+
162+
PdfSigner signer = createPdfSigner(srcFileName, outFileName);
163+
164+
PdfPadesSigner padesSigner = new PdfPadesSigner();
165+
padesSigner.setOcspClient(testOcspClient);
166+
padesSigner.setCrlClient(testCrlClient);
167+
168+
IExternalSignature pks =
169+
new PrivateKeySignature(signRsaPrivateKey, DigestAlgorithms.SHA256, FACTORY.getProviderName());
170+
padesSigner.signWithBaselineLTAProfile(signer, signRsaChain, pks, testTsa);
171+
172+
PadesSigTest.basicCheckSignedDoc(outFileName, "Signature1");
173+
174+
Assert.assertNull(SignaturesCompareTool.compareSignatures(outFileName, cmpFileName));
175+
}
176+
177+
private PdfSigner createPdfSigner(String srcFileName, String outFileName) throws IOException {
178+
PdfSigner signer = new PdfSigner(new PdfReader(srcFileName), FileUtil.getFileOutputStream(outFileName),
179+
new StampingProperties());
180+
signer.setFieldName("Signature1");
181+
signer.getSignatureAppearance()
182+
.setPageRect(new Rectangle(50, 650, 200, 100))
183+
.setReason("Test")
184+
.setLocation("TestCity")
185+
.setLayer2Text("Approval test signature.\nCreated by iText.");
186+
return signer;
187+
}
188+
}

0 commit comments

Comments
 (0)