Classes in this File | Line Coverage | Branch Coverage | Complexity | ||||
LdapSubjectRepository |
|
| 4.2;4.2 |
1 | /* | |
2 | * Copyright (c) 2009, Swiss Federal Department of Defence Civil Protection and Sport | |
3 | * (http://www.vbs.admin.ch) | |
4 | * Copyright (c) 2009, Ergon Informatik AG (http://www.ergon.ch) | |
5 | * All rights reserved. | |
6 | * | |
7 | * Licensed under the Open Permis License which accompanies this distribution, | |
8 | * and is available at http://www.openpermis.org/BSDlicenceKent.txt | |
9 | */ | |
10 | package org.openpermis.repository.basic; | |
11 | ||
12 | import java.io.ByteArrayOutputStream; | |
13 | import java.io.IOException; | |
14 | import java.io.InputStream; | |
15 | import java.net.URI; | |
16 | import java.security.NoSuchAlgorithmException; | |
17 | import java.security.NoSuchProviderException; | |
18 | import java.util.ArrayList; | |
19 | import java.util.Enumeration; | |
20 | import java.util.HashMap; | |
21 | import java.util.Hashtable; | |
22 | import java.util.List; | |
23 | import java.util.Map; | |
24 | ||
25 | import javax.naming.Context; | |
26 | import javax.naming.NamingEnumeration; | |
27 | import javax.naming.NamingException; | |
28 | import javax.naming.directory.Attribute; | |
29 | import javax.naming.directory.SearchControls; | |
30 | import javax.naming.directory.SearchResult; | |
31 | import javax.naming.ldap.InitialLdapContext; | |
32 | ||
33 | import org.bouncycastle.util.encoders.Base64; | |
34 | ||
35 | import org.openpermis.Subject; | |
36 | import org.openpermis.basic.InternalSubject; | |
37 | import org.openpermis.cert.AttributeCertificate; | |
38 | import org.openpermis.cert.CertificateVerifier; | |
39 | import org.openpermis.repository.SubjectRepositoryException; | |
40 | ||
41 | ||
42 | /** | |
43 | * Loads certificate attributes from an LDAP directory and builds up a subject repository from | |
44 | * them. | |
45 | * | |
46 | * This implementation of {@link org.openpermis.repository.SubjectRepository} | |
47 | * connects to an LDAP directory | |
48 | * (either anonymously or using username and password) and looks at all nodes of the specified | |
49 | * search context (node or subtree). For each node, it looks for attributes containing role | |
50 | * assignment attribute certificates. The subject repository is build from all found and | |
51 | * successfully validated role assignment attribute certificates. | |
52 | * Note that it is not relevant in what nodes a role assignment is found. This means that a role | |
53 | * assignment does not necessarily have to be stored in role holders node. | |
54 | * | |
55 | * The search scope can be limited to the specified search context or to the whole subtree it | |
56 | * defines. An arbitrary search filter can be specified in order to limit the number of nodes or | |
57 | * in order to get better performance in large directories. | |
58 | * | |
59 | * This implementation keeps the subject repository in an internal cache. It is therefore not | |
60 | * necessary to query the directory for every call to {@link #retrieveSubject(URI)}. The validity | |
61 | * period of data in the cache can be controlled using method {@link #setCacheTimeout(long)} and | |
62 | * defaults to five seconds. The cache can be disabled leading to a potentially expensive LDAP | |
63 | * query for every call to {@link #retrieveSubject(URI)}. | |
64 | * | |
65 | * @since 0.1.0 | |
66 | */ | |
67 | public class LdapSubjectRepository extends AbstractSubjectRepository { | |
68 | //---- Static | |
69 | ||
70 | /** The attribute name under which attribute certificates are stored in a directory */ | |
71 | private static final String ACE_ATTRIBUTE_NAME = "attributeCertificateAttribute"; | |
72 | ||
73 | /** The default cache timeout */ | |
74 | private static final long DEFAULT_CACHE_TIMEOUT_MILLIS = 5000L; | |
75 | ||
76 | /** Don't limit ldap result size by default */ | |
77 | private static final long DEFAULT_LDAP_SEARCH_LIMIT = 0L; | |
78 | ||
79 | /** Default LDAP version string */ | |
80 | private static final String DEFAULT_LDAP_VERSION = "3"; | |
81 | ||
82 | /** Default is not to use SSL */ | |
83 | private static final boolean DEFAULT_LDAP_USE_SSL = false; | |
84 | ||
85 | /** Default initial context factory */ | |
86 | private static final String DEFAULT_LDAP_INITIAL_CONTEXT_FACTORY = | |
87 | "com.sun.jndi.ldap.LdapCtxFactory"; | |
88 | ||
89 | /** LDAP search conditino that is always true */ | |
90 | private static final String DEFAULT_LDAP_SEARCH_FILTER = "objectClass=*"; | |
91 | ||
92 | /** The default search scope is subtree */ | |
93 | private static final int DEFAULT_SEARCH_SCOPE = SearchControls.SUBTREE_SCOPE; | |
94 | ||
95 | /** Buffer size */ | |
96 | private static final int BUFFER_SIZE = 1024; | |
97 | ||
98 | //---- State | |
99 | ||
100 | /** | |
101 | * The maximum number of results an LDAP directory may return. | |
102 | * 0 = no limit. | |
103 | * Note that there may be a server side limit that cannot be influenced by the client. | |
104 | */ | |
105 | private long ldapSearchLimit; | |
106 | ||
107 | /** | |
108 | * The number of milliseconds the data loaded from the LDAP may be cached. | |
109 | */ | |
110 | private long cacheTimeout; | |
111 | ||
112 | /** | |
113 | * Internal timestamp of latest refresh of the cache. | |
114 | */ | |
115 | private long latestRefresh; | |
116 | ||
117 | /** | |
118 | * The internal data cache. | |
119 | */ | |
120 | private final Map<URI, InternalSubject> subjectsByIdentity; | |
121 | ||
122 | /** | |
123 | * The following state is used in the LDAP queries. | |
124 | */ | |
125 | private String ldapVersion; | |
126 | private boolean useSsl; | |
127 | private String initialContextFactory; | |
128 | private String ldapFilter; | |
129 | private Object[] ldapFilterArgs; | |
130 | private int ldapSearchScope; | |
131 | ||
132 | private String ldapUrl; | |
133 | private String bindPrincipal; | |
134 | private String bindPrincipalPassword; | |
135 | private String ldapSearchBase; | |
136 | ||
137 | /** Attribute certificate data user in unit tests */ | |
138 | private List<byte[]> testAcData; | |
139 | /** Set to true in the case of a unit test without LDAP */ | |
140 | private final boolean unitTestMode; | |
141 | ||
142 | //---- Constructors | |
143 | /** | |
144 | * Creates an LDAP subject repository and internally stores the specified | |
145 | * SOA certificate. It is used for validating the attribute certificates of a subject. | |
146 | * <p> | |
147 | * The public key of the subject in the SOA certificate is considered to be authentic. It is | |
148 | * the callers duty to ensure that it really is authentic. Further, by specifiying the SOA | |
149 | * certificate, its subject is trusted. | |
150 | * </p> | |
151 | * | |
152 | * @param certificateVerifier The certificate verifier used to verify the attribute | |
153 | * certificates. | |
154 | * Must not be <code>null</code>. | |
155 | * | |
156 | * @param ldapUrl The LDAP URL. Example: "ldap://foo.host.com:389" | |
157 | * @param bindPrincipal The distinguished name of the principal used to bind at the directory | |
158 | * to perform the search. Use <code>null</code> as value to anonymously bind. | |
159 | * @param bindPrincipalPassword The password of the principal to bind at the directory to | |
160 | * perform the search. Use <code>null</code> as value to anonymously bind. | |
161 | * @param ldapSearchBase The search base. | |
162 | * @param ldapSearchScope The search level. Must be either {@link SearchControls#ONELEVEL_SCOPE} | |
163 | * or {@link SearchControls#SUBTREE_SCOPE}. | |
164 | ||
165 | * | |
166 | * | |
167 | * @throws SubjectRepositoryException Thrown if an error occurs reading or interpreting the | |
168 | * SOA certificate. | |
169 | * @since 0.1.0 | |
170 | */ | |
171 | public LdapSubjectRepository ( | |
172 | CertificateVerifier certificateVerifier, | |
173 | String ldapUrl, | |
174 | String bindPrincipal, | |
175 | String bindPrincipalPassword, | |
176 | String ldapSearchBase, | |
177 | int ldapSearchScope | |
178 | ) throws SubjectRepositoryException { | |
179 | 0 | super(certificateVerifier); |
180 | 0 | this.unitTestMode = false; |
181 | 0 | this.subjectsByIdentity = new HashMap<URI, InternalSubject>(); |
182 | // init state with default values | |
183 | 0 | this.ldapSearchLimit = DEFAULT_LDAP_SEARCH_LIMIT; |
184 | 0 | this.cacheTimeout = DEFAULT_CACHE_TIMEOUT_MILLIS; |
185 | 0 | this.ldapVersion = DEFAULT_LDAP_VERSION; |
186 | 0 | this.useSsl = DEFAULT_LDAP_USE_SSL; |
187 | 0 | this.initialContextFactory = DEFAULT_LDAP_INITIAL_CONTEXT_FACTORY; |
188 | 0 | this.ldapFilter = DEFAULT_LDAP_SEARCH_FILTER; |
189 | 0 | this.ldapFilterArgs = null; |
190 | 0 | this.ldapSearchScope = DEFAULT_SEARCH_SCOPE; |
191 | ||
192 | // check constructor data | |
193 | 0 | if (ldapUrl == null) { |
194 | 0 | throw new IllegalArgumentException("ldap URL is null"); |
195 | } | |
196 | 0 | this.ldapUrl = ldapUrl; |
197 | // user / pwd may be null for anonymous bind | |
198 | 0 | this.bindPrincipal = bindPrincipal; |
199 | 0 | this.bindPrincipalPassword = bindPrincipalPassword; |
200 | // search base may be empty if it was already part of the url but it may not be null | |
201 | 0 | this.ldapSearchBase = ldapSearchBase == null ? "" : ldapSearchBase; |
202 | // the search scope value must be one of the following three | |
203 | 0 | if ( |
204 | ldapSearchScope != SearchControls.OBJECT_SCOPE && | |
205 | ldapSearchScope != SearchControls.SUBTREE_SCOPE && | |
206 | ldapSearchScope != SearchControls.ONELEVEL_SCOPE | |
207 | ) { | |
208 | 0 | throw new IllegalArgumentException("search scope has an invalid value"); |
209 | } | |
210 | 0 | this.ldapSearchScope = ldapSearchScope; |
211 | ||
212 | // invalidate cache | |
213 | 0 | invalidateCache(); |
214 | 0 | } |
215 | ||
216 | /** | |
217 | * Package private constructor used for unit testing. It loads the role assignments given | |
218 | * in the specified files and uses them as input instead of querying the LDAP directory. | |
219 | * | |
220 | * In this mode, the verification of attribute certificates, the creation of subjects and | |
221 | * the caching may be tested without an LDAP directory. | |
222 | * | |
223 | * @param certificateVerifier The certificate verifier user to verify the attribute | |
224 | * certificates. | |
225 | * Must not be <code>null</code>. | |
226 | * Must not be <code>null</code>. | |
227 | * @param acsFileNames One or more files names pointing to attribute certificates on the | |
228 | * classpath. | |
229 | * @throws SubjectRepositoryException Thrown if an error occurs reading or interpreting the | |
230 | * SOA certificate. | |
231 | * @since 0.1.0 | |
232 | */ | |
233 | LdapSubjectRepository (CertificateVerifier certificateVerifier, String... acsFileNames) | |
234 | throws SubjectRepositoryException | |
235 | { | |
236 | 3 | super(certificateVerifier); |
237 | 3 | this.unitTestMode = true; |
238 | // init ac data list | |
239 | 3 | this.testAcData = new ArrayList<byte[]>(); |
240 | try { | |
241 | ||
242 | 9 | for (String fn : acsFileNames) { |
243 | 6 | ByteArrayOutputStream baos = new ByteArrayOutputStream(); |
244 | 6 | InputStream is = LdapSubjectRepository.class.getResourceAsStream(fn); |
245 | 6 | byte[] buffer = new byte[BUFFER_SIZE]; |
246 | 6 | int readBytes = 0; |
247 | 12 | while ((readBytes = is.read(buffer)) > -1) { |
248 | 6 | baos.write(buffer, 0, readBytes); |
249 | } | |
250 | 6 | is.close(); |
251 | 6 | this.testAcData.add(Base64.encode(baos.toByteArray())); |
252 | } | |
253 | 0 | } catch (IOException e) { |
254 | 0 | throw new SubjectRepositoryException( |
255 | "Cannot setup ldap subject repostiory in test mode.", e | |
256 | ); | |
257 | 3 | } |
258 | 3 | this.subjectsByIdentity = new HashMap<URI, InternalSubject>(); |
259 | 3 | this.cacheTimeout = DEFAULT_CACHE_TIMEOUT_MILLIS; |
260 | 3 | invalidateCache(); |
261 | 3 | } |
262 | ||
263 | //---- Methods | |
264 | ||
265 | /** | |
266 | * Private method that refreshes the internal data cache by quering the LDAP. | |
267 | * @throws SubjectRepositoryException Thrown if the subject repository cannot be built up. | |
268 | ||
269 | */ | |
270 | private void refreshDataCache () throws SubjectRepositoryException | |
271 | { | |
272 | List<byte[]> acsData; | |
273 | 3 | if (this.unitTestMode) { |
274 | 3 | acsData = this.testAcData; |
275 | } else { | |
276 | try { | |
277 | 0 | acsData = getAttributeCertificateData( |
278 | this.ldapUrl, | |
279 | this.bindPrincipal, | |
280 | this.bindPrincipalPassword, | |
281 | this.ldapVersion, | |
282 | this.ldapSearchLimit, | |
283 | this.useSsl, | |
284 | this.initialContextFactory, | |
285 | this.ldapSearchBase, | |
286 | this.ldapSearchScope, | |
287 | this.ldapFilter, | |
288 | this.ldapFilterArgs | |
289 | ); | |
290 | 0 | } catch (NamingException e) { |
291 | 0 | throw new SubjectRepositoryException("Cannot load data from LDAP.", e); |
292 | 0 | } |
293 | } | |
294 | // try to decode them as attribute certificates and verify them | |
295 | 3 | synchronized (this.subjectsByIdentity) { |
296 | 3 | for (byte[] acData : acsData) { |
297 | try { | |
298 | ||
299 | 6 | updateSubjectMap(this.subjectsByIdentity, new AttributeCertificate( |
300 | Base64.decode(acData)) | |
301 | ); | |
302 | 0 | } catch (NoSuchAlgorithmException e) { |
303 | 0 | throw new SubjectRepositoryException( |
304 | "A cryptographic algorithm used for signature verification cannot " + | |
305 | "be retrieved from the crypto provider(s).", | |
306 | e | |
307 | ); | |
308 | 0 | } catch (NoSuchProviderException e) { |
309 | 0 | throw new SubjectRepositoryException("There is no default crypto provider.", e); |
310 | 0 | } catch (IOException e) { |
311 | // ignore this certificate | |
312 | 12 | } |
313 | } | |
314 | 3 | this.latestRefresh = System.currentTimeMillis(); |
315 | 3 | } |
316 | 3 | } |
317 | ||
318 | ||
319 | /** | |
320 | * Allows specifying an LDAP filter expression that is used to search attribute certificates. | |
321 | * Setting a filter also invalidates the cache such that data is re-read from the directory | |
322 | * the next time {@link #retrieveSubject(URI)} is called. | |
323 | * @param filterExpression An LDAP filter expression (following RFC 2254). | |
324 | * @param filterArguments A list of arguments for the filter expressions. May be null. | |
325 | * @return Returns the object this method was called on (fluent interface). | |
326 | * @since 0.1.0 | |
327 | */ | |
328 | public LdapSubjectRepository setLdapFilter (String filterExpression, Object[] filterArguments) { | |
329 | 0 | if (filterExpression != null) { |
330 | 0 | this.ldapFilter = filterExpression; |
331 | 0 | this.ldapFilterArgs = filterArguments.clone(); |
332 | 0 | invalidateCache(); |
333 | } | |
334 | 0 | return this; |
335 | } | |
336 | ||
337 | /** | |
338 | * Allows specifying the initial LDAP context factory that is used to search attribute | |
339 | * certificates. | |
340 | * Setting the factory also invalidates the cache such that data is re-read from the directory | |
341 | * the next time {@link #retrieveSubject(URI)} is called. | |
342 | * @param initialLdapContextFactory The class name of the initial LDAP context factory. | |
343 | * @return Returns the object this method was called on (fluent interface). | |
344 | * @since 0.1.0 | |
345 | */ | |
346 | public LdapSubjectRepository setInitialLdapContextFactory (String initialLdapContextFactory) { | |
347 | 0 | if (initialLdapContextFactory == null) { |
348 | 0 | throw new IllegalArgumentException("inital LDAP context factory is null"); |
349 | } | |
350 | 0 | this.initialContextFactory = initialLdapContextFactory; |
351 | 0 | invalidateCache(); |
352 | 0 | return this; |
353 | } | |
354 | ||
355 | /** | |
356 | * Sets the maximum number of results an LDAP directory may return in a search. | |
357 | * Zero (0) means "no limit" which is the default. | |
358 | * Note that there may be a server side limit that cannot be influenced by the client. | |
359 | * @param maxResults The maximum number of results. | |
360 | * @return Returns the object this method was called on (fluent interface). | |
361 | * @since 0.1.0 | |
362 | */ | |
363 | public LdapSubjectRepository setLdapSearchLimit (long maxResults) { | |
364 | 0 | this.ldapSearchLimit = maxResults; |
365 | 0 | return this; |
366 | } | |
367 | ||
368 | /** | |
369 | * Sets the cache timeout in milliseconds. The cache timeout defines how long data loaded | |
370 | * from the directory is considered valid before reloading it again. | |
371 | * Use the value zero to disable caching and use Long.MAX_VALUE to cache the data for ever. | |
372 | * @param milliseconds The number of milliseconds of the cache timeout. | |
373 | * @return Returns the object this method was called on (fluent interface). | |
374 | * @since 0.1.0 | |
375 | */ | |
376 | public LdapSubjectRepository setCacheTimeout (long milliseconds) { | |
377 | 0 | synchronized (this.subjectsByIdentity) { |
378 | 0 | this.cacheTimeout = milliseconds < 0 ? 0 : milliseconds; |
379 | 0 | } |
380 | 0 | return this; |
381 | } | |
382 | ||
383 | /** | |
384 | * Invalidates the internal data cache. Forces the data to be reload the next time | |
385 | * {@link #retrieveSubject(URI)} is called. | |
386 | * @since 0.1.0 | |
387 | */ | |
388 | public void invalidateCache () { | |
389 | 3 | synchronized (this.subjectsByIdentity) { |
390 | 3 | this.latestRefresh = -1L; |
391 | 3 | } |
392 | ||
393 | 3 | } |
394 | ||
395 | /** | |
396 | * Searches in on specified directory tree (context) for entries with attribute certificates | |
397 | * and returns a list of byte arrays containing the data. | |
398 | * | |
399 | * @param ldapUrl The LDAP URL. Example: "ldap://foo.host.com:389" | |
400 | * @param bindPrincipal The distinguished name of the principal used to bind at the directory | |
401 | * to perform the search. Use <code>null</code> as value to anonymously bind. | |
402 | * @param bindPrincipalPassword The password of the principal to bind at the directory to | |
403 | * perform the search. Use <code>null</code> as value to anonymously bind. | |
404 | * @param ldapVersion The LDAP version string. Normally, this is "3". | |
405 | * @param ldapSearchLimit The maximum number of results to return in an LDAP query. | |
406 | * @param useSsl Set to true if SSL should be used to connect to the ldap server. If set to true | |
407 | * the SSL factory provided by the JVM is used and a corresponding trust- and keystore must be | |
408 | * configured in the JVM. | |
409 | * @param initialContextFactory The factory used to create initial LDAP context object. | |
410 | * This depends on the used JVM. To use the SUN implementation use the value | |
411 | * "com.sun.jndi.ldap.LdapCtxFactory". | |
412 | * @param ldapSearchBase The search base. | |
413 | * @param ldapSearchLevel The search level. Must be either {@link SearchControls#ONELEVEL_SCOPE} | |
414 | * or {@link SearchControls#SUBTREE_SCOPE}. | |
415 | * @param filter An LDAP filter expression (following RFC 2254). | |
416 | * @param filterArgs The filter arguments. | |
417 | * @return A possibly empty list of byte arrays containing attribute certificates. | |
418 | * @throws NamingException If there is an error searching the directory or decoding the response | |
419 | * from the directory. | |
420 | */ | |
421 | private static List<byte[]> getAttributeCertificateData ( | |
422 | String ldapUrl, | |
423 | String bindPrincipal, | |
424 | String bindPrincipalPassword, | |
425 | String ldapVersion, | |
426 | long ldapSearchLimit, | |
427 | boolean useSsl, | |
428 | String initialContextFactory, | |
429 | String ldapSearchBase, | |
430 | int ldapSearchLevel, | |
431 | String filter, | |
432 | Object[] filterArgs | |
433 | ) throws NamingException { | |
434 | // setup the ldap context for searching | |
435 | 0 | Hashtable<String, String> env = new Hashtable<String, String>(); |
436 | 0 | env.put(Context.INITIAL_CONTEXT_FACTORY, initialContextFactory); |
437 | 0 | env.put(Context.PROVIDER_URL, ldapUrl); |
438 | 0 | env.put(Context.SECURITY_AUTHENTICATION, "simple"); |
439 | 0 | if (bindPrincipal != null) { |
440 | 0 | env.put(Context.SECURITY_PRINCIPAL, bindPrincipal); |
441 | 0 | env.put(Context.SECURITY_CREDENTIALS, bindPrincipalPassword); |
442 | } | |
443 | 0 | env.put("java.naming.ldap.attributes.binary", ACE_ATTRIBUTE_NAME); |
444 | 0 | env.put("java.naming.ldap.version", ldapVersion); |
445 | 0 | if (useSsl) { |
446 | 0 | env.put(Context.SECURITY_PROTOCOL, "ssl"); |
447 | } | |
448 | // create search context | |
449 | 0 | InitialLdapContext context = new InitialLdapContext(env, null); |
450 | ||
451 | // setup LDAP search controls | |
452 | 0 | SearchControls searchControls = new SearchControls(); |
453 | 0 | searchControls.setReturningAttributes(new String[] {ACE_ATTRIBUTE_NAME}); |
454 | 0 | searchControls.setCountLimit(ldapSearchLimit); |
455 | 0 | searchControls.setSearchScope(ldapSearchLevel); |
456 | // perform the LDAP query | |
457 | 0 | Enumeration<SearchResult> resultFromLdap = |
458 | context.search(ldapSearchBase, filter, filterArgs, searchControls); | |
459 | // prepare result structure | |
460 | 0 | final List<byte[]> res = new ArrayList<byte[]>(); |
461 | ||
462 | // process the result from the LDAP, i.e. extract the attribute certificates | |
463 | 0 | while (resultFromLdap.hasMoreElements()) { |
464 | 0 | final SearchResult searchResult = resultFromLdap.nextElement(); |
465 | 0 | final NamingEnumeration<? extends Attribute> names = |
466 | searchResult.getAttributes().getAll(); | |
467 | 0 | while (names.hasMore()) { |
468 | 0 | Attribute at = names.next(); |
469 | // note that there may be more than one value for this attribute !! | |
470 | 0 | NamingEnumeration<?> values = at.getAll(); |
471 | 0 | while (values.hasMore()) { |
472 | // the cast to byte[] is ok because the context was setup such that the | |
473 | //requested attribute is returned as binary | |
474 | 0 | res.add((byte[]) values.next()); |
475 | } | |
476 | 0 | } |
477 | 0 | } |
478 | 0 | return res; |
479 | } | |
480 | ||
481 | //---- SubjectRepository | |
482 | ||
483 | /** | |
484 | * @since 0.1.0 | |
485 | */ | |
486 | // TODO Any Function does not correctly implement interface (Should never return null). | |
487 | public Subject retrieveSubject (URI identity) throws SubjectRepositoryException { | |
488 | final Subject result; | |
489 | 4 | synchronized (this.subjectsByIdentity) { |
490 | 4 | if (System.currentTimeMillis() < this.latestRefresh + this.cacheTimeout) { |
491 | 1 | result = this.subjectsByIdentity.get(identity); |
492 | } else { | |
493 | try { | |
494 | 3 | refreshDataCache(); |
495 | 0 | } catch (SubjectRepositoryException e) { |
496 | 0 | throw new SubjectRepositoryException("Cannot update subject repository.", e); |
497 | 3 | } |
498 | 3 | result = this.subjectsByIdentity.get(identity); |
499 | } | |
500 | 4 | } |
501 | 4 | return result; |
502 | } | |
503 | } |