1 /**
2 * Store database credentials outside the sourcecode
3 *
4 * This module makes it easy to load credentials from external files and use it for Database Clients.
5 * This module was build with MySQL/MariaDB credentials in mind - but it can surely be used for similar credential structures.
6 *
7 * Authors: Martin Brzenska
8 ' Licence: MIT
9 *
10 */
11 module credexil;
12 
13 import std.typecons : Nullable;
14 
15 /**
16 * Holds credentials used for a login
17 *
18 * Authors: Martin Brzenska
19 ' Licence: MIT
20 */
21 struct Credential
22 {
23     private string __host;
24     private Nullable!ushort __port;
25     private Nullable!string __user;
26     private Nullable!string __pwd;
27     private Nullable!string __db;
28     private string __connectionName;
29 
30     public:
31         ///
32         @property Credential host(string host)
33         {
34             this.__host = host;
35             return this;
36         }
37         ///
38         @property string host() { return this.__host; }
39 
40         ///
41         @property Credential port(ushort port)
42         {
43             import std.conv : to;
44             this.__port = port;
45             return this;
46         }
47         Nullable!ushort port() @property
48         { return this.__port; }
49         ///
50         @property Credential user(string user)
51         {
52             this.__user = user;
53             return this;
54         }
55         ///
56         @property Nullable!string user() { return this.__user; }
57 
58         ///
59         @property Credential pwd(string pwd)
60         {
61             this.__pwd = pwd;
62             return this;
63         }
64         ///
65         @property Nullable!string pwd() { return this.__pwd; }
66 
67         ///
68         @property Credential db(string db)
69         {
70             this.__db = db;
71             return this;
72         }
73         ///
74         @property Nullable!string db() { return this.__db; }
75 
76         ///
77         @property Credential connectionName(string connectionName)
78         {
79             this.__connectionName = connectionName;
80             return this;
81         }
82         ///
83         const string connectionName() @property { return this.__connectionName; }
84 
85         ///
86         @property string connectionString()
87         {
88             import std.conv : to;
89             import std..string : chop;
90             return
91                 chop(
92                     "host="~this.host~";"
93                     ~ (this.port.isNull ? "" : "port="~this.port.to!string~";")
94                     ~ (this.user.isNull ? "" : "user="~this.user~";")
95                     ~ (this.pwd.isNull ? "" : "pwd="~this.pwd~";")
96                     ~ (this.db.isNull ? "" : "db="~this.db~";")
97                 );
98         }
99     ///
100     unittest
101     {
102         auto cred = Credential().host("localhost");
103         assert(cred.connectionString == "host=localhost");
104 
105         cred = Credential().host("127.0.0.1").port(3306);
106         assert(cred.connectionString == "host=127.0.0.1;port=3306");
107 
108         cred = Credential().host("localhost").user("testuser").db("SchemaXY");
109         assert(cred.connectionString == "host=localhost;user=testuser;db=SchemaXY");
110         assert(cred.host == "localhost");
111 
112     }
113 
114     string toString()
115     {
116         return this.connectionString;
117     }
118 }
119 
120 /**
121 * Loads credentials from the given file
122 *
123 * The file to load must have the appropriate format (see example below).
124 * One file can hold multiple credential entities, each consisting of a name and a set of variables.
125 *
126 * Examples:
127 * -----------------
128 * [connection_name]
129 * host = 127.0.0.1
130 * port = 3306
131 * user = MyUser
132 * pwd = MyPassword
133 * db = MyDatabase
134 * -----------------
135 *
136 * Throws: CredentialException if file is not formated as expected
137 * Throws: ErrnoException if file could not be opened.
138 *
139 * Authors: Martin Brzenska
140 ' Licence: MIT
141 *
142 */
143 public Credential[] load(string file)
144 {
145     import std.algorithm.searching : maxElement , startsWith;
146     import std.array : array;
147     import std.format : format;
148     import std.regex;
149     import std.stdio : File;
150     import std..string : strip;
151     import std.uni : toLower;
152     import std.conv : to , ConvOverflowException , ConvException;
153 
154     auto connectionName = ctRegex!(`^[\s]*\[([^\]]+)\][\s]*$`);
155     auto variable = ctRegex!(`^[\s]*(host|port|user|pwd|db)[\s]*=[\s]*(.*)[\s]*$`);
156 
157     immutable string FERR_OBLIGATORY_PARAMS = "'host' variable is missing for Credential '%s'";
158 
159     Credential[] credentials;
160 
161     /*
162     * is temporarily used to build up a Credential Entity
163     */
164     Credential credential;
165 
166     foreach( file_lineno , line ; File(file).byLineCopy.array )
167     {
168         auto nameCapture = line.matchFirst(connectionName);
169         if( ! nameCapture.empty )
170         {
171             /*
172             * This is the beginning of a new connection
173             */
174 
175             if(credential.connectionName != credential.connectionName.init)
176             {
177                 /*
178                 * If there was a connection before, make sure that all obligatory variables from the previous credentialEntity are set.
179                 * If ok, add the previous entity to the result array.
180                 */
181                 if(credential.host == credential.host.init)
182                 {
183                     throw new CredentialException(format(FERR_OBLIGATORY_PARAMS , credential.connectionName ));
184                 }
185                 else
186                 {
187                     credentials ~= credential;
188                 }
189             }
190 
191             /*
192             * Check if we already had a connection with this name
193             */
194             foreach( cred ; credentials )
195             {
196                 if( cred.connectionName == nameCapture[1].toLower )
197                 {
198                     throw new CredentialException( format( "The Credential Entity Name must be unique ('%s')" , nameCapture[1].toLower ) );
199                 }
200             }
201 
202             credential = Credential();
203             credential.connectionName = nameCapture[1].toLower;
204         }
205 
206         auto variableCapture = line.matchFirst(variable);
207         if( ! variableCapture.empty )
208         {
209             switch(variableCapture[1].toLower)
210             {
211                 case "host":
212                     if(credential.host.length)
213                         throw new CredentialException(format("Multiple definitions for '%s' in '%s'" ,  variableCapture[1].toLower,  credential.connectionName) );
214                     if( ! variableCapture[2].length)
215                         throw new CredentialException(format("variable '%s' cannot be empty at line '%s:%d'" , variableCapture[1].toLower , file , 1+file_lineno) );
216 
217                     credential.host = variableCapture[2];
218                     break;
219 
220                 case "port":
221                     if( ! credential.port.isNull)
222                         throw new CredentialException(format("Multiple definitions for port in '%s'" , credential.connectionName) );
223                     if( ! variableCapture[2].length)
224                         throw new CredentialException(format("if '%s' is given, it cannot be empty at line '%s:%d'" , variableCapture[1].toLower , file ,  1+file_lineno) );
225 
226                     try {
227                         credential.port = variableCapture[2].to!ushort;
228                     }
229                     catch( ConvException e )
230                     {
231                         throw new CredentialException(format( "port must have a value between %d and %d in '%s:%d'" , credential.port.min , credential.port.max , file , 1+file_lineno ));
232                     }
233                     break;
234 
235                 case "user":
236                     if( ! credential.user.isNull)
237                         throw new CredentialException(format("Multiple definitions for user in '%s'" , credential.connectionName) );
238                     if( ! variableCapture[2].length)
239                         throw new CredentialException(format("apply a non empty value to '%s' or leave it completely in '%s:%d'" , variableCapture[1].toLower , file ,  1+file_lineno) );
240 
241                     credential.user = variableCapture[2];
242                     break;
243 
244                 case "pwd":
245                     if( ! credential.pwd.isNull )
246                         throw new CredentialException(format("Multiple definitions for pwd in '%s'" , credential.connectionName) );
247                     if( ! variableCapture[2].length)
248                         throw new CredentialException(format("apply a non empty value to '%s' or leave it completely in '%s:%d'" , variableCapture[1].toLower , file ,  1+file_lineno) );
249 
250                     credential.pwd = variableCapture[2];
251                     break;
252 
253                 case "db":
254                     if( ! credential.db.isNull )
255                         throw new CredentialException(format("Multiple definitions for db in '%s'" , credential.connectionName) );
256                     credential.db = variableCapture[2];
257                     break;
258 
259                 default:
260                     throw new CredentialException(format("unknown variable  in '%s:%d'" , file , 1+file_lineno) );
261             }
262         }
263 
264         if(
265             nameCapture.empty
266             && variableCapture.empty
267             && false == line.startsWith("#",";")
268             && line.strip.length
269         ) {
270             throw new CredentialException(format("Syntax Error at line %d" , 1+file_lineno) );
271         }
272     }
273 
274     /*
275     * Handle the last Credential Entity
276     * Check if all obligatory parameters are given
277     */
278     if(
279         credential.connectionName != credential.connectionName.init
280         && credential.host == credential.host.init
281     ) {
282         throw new CredentialException(format(FERR_OBLIGATORY_PARAMS , credential.connectionName ));
283     }
284     else
285     {
286         credentials ~= credential;
287     }
288 
289     return credentials;
290 }
291 ///
292 unittest {
293     Credential[] credentials = .load("./source/test.cred");
294     assert(credentials.length == 3);
295 
296     assert(credentials.get("connection_name").connectionName == "connection_name");
297     assert(credentials.get("connection_name").host == "127.0.0.1");
298     assert(credentials.get("connection_name").port == 3306);
299     assert(credentials.get("connection_name").user == "MyUser");
300     assert(credentials.get("connection_name").pwd == "MyPassword");
301     assert(credentials.get("connection_name").db == "MyDatabase");
302     assert(credentials.get("connection_name").connectionString == "host=127.0.0.1;port=3306;user=MyUser;pwd=MyPassword;db=MyDatabase");
303 
304 }
305 unittest
306 {
307     Credential _2ndTestCred = .load("./source/test.cred").get("2nd test connection");
308 
309     assert(_2ndTestCred.connectionName == "2nd test connection" );
310     assert(_2ndTestCred.connectionString == "host=localhost;user=some_user;pwd=1234" );
311     assert(_2ndTestCred.host == "localhost" );
312     assert(_2ndTestCred.user == "some_user" );
313     assert(_2ndTestCred.pwd == "1234" );
314     assert(_2ndTestCred.port.isNull );
315     assert(_2ndTestCred.db.isNull );
316 
317 }
318 
319 /**
320 * Gets the Credential entity with the given name
321 *
322 * Throws: core.exception.RangeError if name cannot be found
323 *
324 * Authors: Martin Brzenska
325 ' Licence: MIT
326 */
327 Credential get(in Credential[] credentials , string name )
328 {
329     import std.algorithm.searching : countUntil;
330     return credentials[ credentials.countUntil!(cred => cred.connectionName == name) ];
331 }
332 unittest
333 {
334     auto credentials = .load("./source/test.cred");
335     assert(credentials.get("connection_name").connectionName == "connection_name");
336 }
337 
338 class CredentialException : Exception
339 {
340     this(string msg) { super(msg); }
341 }