adtools 0.0.1pre

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,4 @@
1
+ html
2
+ pkg
3
+ .DS_STORE
4
+ spec/test_values.yml
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source :rubygems
2
+
3
+ gemspec
data/License ADDED
@@ -0,0 +1,2 @@
1
+ Adtools is based on active_directory by Adam T Kerr
2
+ http://github.com/ajrkerr/active_directory
@@ -0,0 +1,3 @@
1
+ require 'bundler'
2
+
3
+ Bundler::GemHelper.install_tasks
@@ -0,0 +1,26 @@
1
+ = Adtools
2
+
3
+ Ruby bindings for Microsofts Active Directory (LDAP)
4
+
5
+ == Install
6
+
7
+ For use in scripts:
8
+
9
+ $ gem install adtools
10
+
11
+ For rails / anything using bundler add this to your Gemfile:
12
+
13
+ gem 'adtools'
14
+
15
+ == Usage
16
+
17
+ First off you need to configure Adtools, to do this call .configure:
18
+
19
+ Adtools.configure do |c|
20
+ c.domain = "example.com"
21
+ c.query_user = "Administrator"
22
+ c.query_password = "P4$$w0rd"
23
+ end
24
+
25
+ You can also specify a port, server and base string, but Adtools will assume these for you and _you may not need them_
26
+
@@ -0,0 +1,21 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+ require 'adtools/version'
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "adtools"
7
+ s.version = Adtools::Version
8
+ s.platform = Gem::Platform::RUBY
9
+ s.authors = ["Adam \"Arcath\" Laycock"]
10
+ s.email = ["gems@arcath.net"]
11
+ s.homepage = "http://adtools.arcath.net"
12
+ s.summary = "Ruby bindings for Microsofts Active Directory (LDAP), a fork of ActiveDirectory"
13
+
14
+ s.add_development_dependency "rspec"
15
+ s.add_dependency "net-ldap"
16
+
17
+ s.files = `git ls-files`.split("\n")
18
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
19
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
20
+ s.require_paths = ["lib"]
21
+ end
@@ -0,0 +1,112 @@
1
+ #-- license
2
+ #
3
+ # Based on original code by Justin Mecham and James Hunt
4
+ # at http://rubyforge.org/projects/activedirectory
5
+ #
6
+ # This program is free software: you can redistribute it and/or modify
7
+ # it under the terms of the GNU General Public License as published by
8
+ # the Free Software Foundation, either version 3 of the License, or
9
+ # (at your option) any later version.
10
+ #
11
+ # This program is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU General Public License
17
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
18
+ #
19
+ #++ license
20
+
21
+ require 'net/ldap'
22
+
23
+ require 'adtools/config'
24
+
25
+ require 'adtools/base.rb'
26
+ require 'adtools/container.rb'
27
+ require 'adtools/member.rb'
28
+
29
+ require 'adtools/user.rb'
30
+ require 'adtools/group.rb'
31
+ require 'adtools/computer.rb'
32
+ require 'adtools/ou'
33
+
34
+ require 'adtools/field_type/password.rb'
35
+ require 'adtools/field_type/binary.rb'
36
+ require 'adtools/field_type/date.rb'
37
+ require 'adtools/field_type/timestamp.rb'
38
+ require 'adtools/field_type/dn_array.rb'
39
+ require 'adtools/field_type/user_dn_array.rb'
40
+ require 'adtools/field_type/group_dn_array.rb'
41
+ require 'adtools/field_type/member_dn_array.rb'
42
+
43
+ module Adtools
44
+
45
+ def self.configure
46
+ @config = Config.new
47
+ yield(@config)
48
+ Adtools::Base.setup(@config.settings_hash)
49
+ end
50
+
51
+ def self.config
52
+ @config
53
+ end
54
+ #Special Fields
55
+ def self.special_fields
56
+ @@special_fields
57
+ end
58
+
59
+ def self.special_fields= sp_fields
60
+ @@special_fields = sp_fields
61
+ end
62
+
63
+ @@special_fields = {
64
+
65
+ #All objects in the AD
66
+ :Base => {
67
+ :objectguid => :Binary,
68
+ :whencreated => :Date,
69
+ :whenchanged => :Date,
70
+ :memberof => :DnArray,
71
+ },
72
+
73
+ #User objects
74
+ :User => {
75
+ :objectguid => :Binary,
76
+ :whencreated => :Date,
77
+ :whenchanged => :Date,
78
+ :objectsid => :Binary,
79
+ :msexchmailboxguid => :Binary,
80
+ :msexchmailboxsecuritydescriptor => :Binary,
81
+ :lastlogontimestamp => :Timestamp,
82
+ :pwdlastset => :Timestamp,
83
+ :accountexpires => :Timestamp,
84
+ :memberof => :MemberDnArray,
85
+ },
86
+
87
+ #Group objects
88
+ :Group => {
89
+ :objectguid => :Binary,
90
+ :whencreated => :Date,
91
+ :whenchanged => :Date,
92
+ :objectsid => :Binary,
93
+ :memberof => :GroupDnArray,
94
+ :member => :MemberDnArray,
95
+ },
96
+
97
+ #OU objects
98
+ #:Ou => {
99
+ # :objectguid => :Binary,
100
+ #},
101
+
102
+ #Computer Objects
103
+ :Computer => {
104
+ :objectguid => :Binary,
105
+ :whencreated => :Date,
106
+ :whenchanged => :Date,
107
+ :objectsid => :Binary,
108
+ :memberof => :GroupDnArray,
109
+ :member => :MemberDnArray,
110
+ }
111
+ }
112
+ end
@@ -0,0 +1,584 @@
1
+ #-- license
2
+ #
3
+ # Based on original code by Justin Mecham and James Hunt
4
+ # at http://rubyforge.org/projects/activedirectory
5
+ #
6
+ # This program is free software: you can redistribute it and/or modify
7
+ # it under the terms of the GNU General Public License as published by
8
+ # the Free Software Foundation, either version 3 of the License, or
9
+ # (at your option) any later version.
10
+ #
11
+ # This program is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU General Public License
17
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
18
+ #
19
+ #++ license
20
+
21
+ module Adtools
22
+ #
23
+ # Base class for all Ruby/ActiveDirectory Entry Objects (like User and Group)
24
+ #
25
+ class Base
26
+ #
27
+ # A Net::LDAP::Filter object that doesn't do any filtering
28
+ # (outside of check that the CN attribute is present. This
29
+ # is used internally for specifying a 'no filter' condition
30
+ # for methods that require a filter object.
31
+ #
32
+ NIL_FILTER = Net::LDAP::Filter.pres('cn')
33
+
34
+ @@ldap = nil
35
+ @@ldap_connected = false
36
+ @@caching = false
37
+ @@cache = {}
38
+
39
+ #
40
+ # Configures the connection for the Ruby/ActiveDirectory library.
41
+ #
42
+ # For example:
43
+ #
44
+ # ActiveDirectory::Base.setup(
45
+ # :host => 'domain_controller1.example.org',
46
+ # :port => 389,
47
+ # :base => 'dc=example,dc=org',
48
+ # :auth => {
49
+ # :username => 'querying_user@example.org',
50
+ # :password => 'querying_users_password'
51
+ # }
52
+ # )
53
+ #
54
+ # This will configure Ruby/ActiveDirectory to connect to the domain
55
+ # controller at domain_controller1.example.org, using port 389. The
56
+ # domain's base LDAP dn is expected to be 'dc=example,dc=org', and
57
+ # Ruby/ActiveDirectory will try to bind as the
58
+ # querying_user@example.org user, using the supplied password.
59
+ #
60
+ # Currently, there can be only one active connection per
61
+ # execution context.
62
+ #
63
+ # For more advanced options, refer to the Net::LDAP.new
64
+ # documentation.
65
+ #
66
+ def self.setup(settings)
67
+ @@settings = settings
68
+ @@ldap = Net::LDAP.new(settings)
69
+ end
70
+
71
+ def self.error
72
+ "#{@@ldap.get_operation_result.code}: #{@@ldap.get_operation_result.message}"
73
+ end
74
+
75
+ ##
76
+ # Return the last errorcode that ldap generated
77
+ def self.error_code
78
+ @@ldap.get_operation_result.code
79
+ end
80
+
81
+ ##
82
+ # Check to see if the last query produced an error
83
+ # Note: Invalid username/password combinations will not
84
+ # produce errors
85
+ def self.error?
86
+ @@ldap.nil? ? false : @@ldap.get_operation_result.code != 0
87
+ end
88
+
89
+ ##
90
+ # Check to see if we are connected to the LDAP server
91
+ # This method will try to connect, if we haven't already
92
+ def self.connected?
93
+ begin
94
+ @@ldap_connected ||= @@ldap.bind unless @@ldap.nil?
95
+ @@ldap_connected
96
+ rescue Net::LDAP::LdapError => e
97
+ false
98
+ end
99
+ end
100
+
101
+ ##
102
+ # Check to see if result caching is enabled
103
+ def self.cache?
104
+ @@caching
105
+ end
106
+
107
+ ##
108
+ # Clears the cache
109
+ def self.clear_cache
110
+ @@cache = {}
111
+ end
112
+
113
+ ##
114
+ # Enable caching for queries against the DN only
115
+ # This is to prevent membership lookups from hitting the
116
+ # AD unnecessarilly
117
+ def self.enable_cache
118
+ @@caching = true
119
+ end
120
+
121
+ ##
122
+ # Disable caching
123
+ def self.disable_cache
124
+ @@caching = false
125
+ end
126
+
127
+ def self.filter # :nodoc:
128
+ NIL_FILTER
129
+ end
130
+
131
+ def self.required_attributes # :nodoc:
132
+ {}
133
+ end
134
+
135
+ #
136
+ # Check to see if any entries matching the passed criteria exists.
137
+ #
138
+ # Filters should be passed as a hash of
139
+ # attribute_name => expected_value, like:
140
+ #
141
+ # User.exists?(
142
+ # :sn => 'Hunt',
143
+ # :givenName => 'James'
144
+ # )
145
+ #
146
+ # which will return true if one or more User entries have an
147
+ # sn (surname) of exactly 'Hunt' and a givenName (first name)
148
+ # of exactly 'James'.
149
+ #
150
+ # Partial attribute matches are available. For instance,
151
+ #
152
+ # Group.exists?(
153
+ # :description => 'OldGroup_*'
154
+ # )
155
+ #
156
+ # would return true if there are any Group objects in
157
+ # Active Directory whose descriptions start with OldGroup_,
158
+ # like OldGroup_Reporting, or OldGroup_Admins.
159
+ #
160
+ # Note that the * wildcard matches zero or more characters,
161
+ # so the above query would also return true if a group named
162
+ # 'OldGroup_' exists.
163
+ #
164
+ def self.exists?(filter_as_hash)
165
+ criteria = make_filter_from_hash(filter_as_hash) & filter
166
+ (@@ldap.search(:filter => criteria).size > 0)
167
+ end
168
+
169
+ #
170
+ # Whether or not the entry has local changes that have not yet been
171
+ # replicated to the Active Directory server via a call to Base#save
172
+ #
173
+ def changed?
174
+ !@attributes.empty?
175
+ end
176
+
177
+ ##
178
+ # Makes a single filter from a given key and value
179
+ # It will try to encode an array if there is a process for it
180
+ # Otherwise, it will treat it as an or condition
181
+ def self.make_filter(key, value)
182
+ #Join arrays using OR condition
183
+ if value.is_a? Array
184
+ filter = ~NIL_FILTER
185
+
186
+ value.each do |v|
187
+ filter |= Net::LDAP::Filter.eq(key, encode_field(key, v).to_s)
188
+ end
189
+ else
190
+ filter = Net::LDAP::Filter.eq(key, encode_field(key, value).to_s)
191
+ end
192
+
193
+ return filter
194
+ end
195
+
196
+ def self.make_filter_from_hash(hash) # :nodoc:
197
+ return NIL_FILTER if hash.nil? || hash.empty?
198
+
199
+ filter = NIL_FILTER
200
+
201
+ hash.each do |key, value|
202
+ filter &= make_filter(key, value)
203
+ end
204
+
205
+ return filter
206
+ end
207
+
208
+ #
209
+ # Performs a search on the Active Directory store, with similar
210
+ # syntax to the Rails ActiveRecord#find method.
211
+ #
212
+ # The first argument passed should be
213
+ # either :first or :all, to indicate that we want only one
214
+ # (:first) or all (:all) results back from the resultant set.
215
+ #
216
+ # The second argument should be a hash of attribute_name =>
217
+ # expected_value pairs.
218
+ #
219
+ # User.find(:all, :sn => 'Hunt')
220
+ #
221
+ # would find all of the User objects in Active Directory that
222
+ # have a surname of exactly 'Hunt'. As with the Base.exists?
223
+ # method, partial searches are allowed.
224
+ #
225
+ # This method always returns an array if the caller specifies
226
+ # :all for the search e (first argument). If no results
227
+ # are found, the array will be empty.
228
+ #
229
+ # If you call find(:first, ...), you will either get an object
230
+ # (a User or a Group) back, or nil, if there were no entries
231
+ # matching your filter.
232
+ #
233
+ def self.find(*args)
234
+ return false unless connected?
235
+
236
+ options = {
237
+ :filter => (args[1].nil?) ? NIL_FILTER : args[1],
238
+ :in => ''
239
+ }
240
+
241
+ cached_results = find_cached_results(args[1])
242
+ return cached_results if cached_results or cached_results.nil?
243
+
244
+ options[:in] = [ options[:in].to_s, @@settings[:base] ].delete_if { |part| part.empty? }.join(",")
245
+
246
+ if options[:filter].is_a? Hash
247
+ options[:filter] = make_filter_from_hash(options[:filter])
248
+ end
249
+
250
+ options[:filter] = options[:filter] & filter unless self.filter == NIL_FILTER
251
+
252
+ if (args.first == :all)
253
+ find_all(options)
254
+ elsif (args.first == :first)
255
+ find_first(options)
256
+ else
257
+ raise ArgumentError, 'Invalid specifier (not :all, and not :first) passed to find()'
258
+ end
259
+ end
260
+
261
+ ##
262
+ # Searches the cache and returns the result
263
+ # Returns false on failure, nil on wrong object type
264
+ #
265
+ def self.find_cached_results(filters)
266
+ return false unless cache?
267
+
268
+ #Check to see if we're only looking for :distinguishedname
269
+ return false unless filters.is_a? Hash and filters.keys == [:distinguishedname]
270
+
271
+ #Find keys we're looking up
272
+ dns = filters[:distinguishedname]
273
+
274
+ if dns.kind_of? Array
275
+ result = []
276
+
277
+ dns.each do |dn|
278
+ entry = @@cache[dn]
279
+
280
+ #If the object isn't in the cache just run the query
281
+ return false if entry.nil?
282
+
283
+ #Only permit objects of the type we're looking for
284
+ result << entry if entry.kind_of? self
285
+ end
286
+
287
+ return result
288
+ else
289
+ return false unless @@cache.key? dns
290
+ return @@cache[dns] if @@cache[dns].is_a? self
291
+ end
292
+ end
293
+
294
+ def self.find_all(options)
295
+ results = []
296
+ ldap_objs = @@ldap.search(:filter => options[:filter], :base => options[:in]) || []
297
+
298
+ ldap_objs.each do |entry|
299
+ ad_obj = new(entry)
300
+ @@cache[entry.dn] = ad_obj unless ad_obj.instance_of? Base
301
+ results << ad_obj
302
+ end
303
+
304
+ results
305
+ end
306
+
307
+ def self.find_first(options)
308
+ ldap_result = @@ldap.search(:filter => options[:filter], :base => options[:in])
309
+ return nil if ldap_result.empty?
310
+
311
+ ad_obj = new(ldap_result[0])
312
+ @@cache[ad_obj.dn] = ad_obj unless ad_obj.instance_of? Base
313
+ return ad_obj
314
+ end
315
+
316
+ def self.method_missing(name, *args) # :nodoc:
317
+ name = name.to_s
318
+ if (name[0,5] == 'find_')
319
+ find_spec, attribute_spec = parse_finder_spec(name)
320
+ raise ArgumentError, "find: Wrong number of arguments (#{args.size} for #{attribute_spec.size})" unless args.size == attribute_spec.size
321
+ filters = {}
322
+ [attribute_spec,args].transpose.each { |pr| filters[pr[0]] = pr[1] }
323
+ find(find_spec, :filter => filters)
324
+ else
325
+ super name.to_sym, args
326
+ end
327
+ end
328
+
329
+ def self.parse_finder_spec(method_name) # :nodoc:
330
+ # FIXME: This is a prime candidate for a
331
+ # first-class object, FinderSpec
332
+
333
+ method_name = method_name.gsub(/^find_/,'').gsub(/^by_/,'first_by_')
334
+ find_spec, attribute_spec = *(method_name.split('_by_'))
335
+ find_spec = find_spec.to_sym
336
+ attribute_spec = attribute_spec.split('_and_').collect { |s| s.to_sym }
337
+
338
+ return find_spec, attribute_spec
339
+ end
340
+
341
+ def ==(other) # :nodoc:
342
+ return false if other.nil?
343
+ other[:objectguid] == get_attr(:objectguid)
344
+ end
345
+
346
+ #
347
+ # Returns true if this entry does not yet exist in Active Directory.
348
+ #
349
+ def new_record?
350
+ @entry.nil?
351
+ end
352
+
353
+ #
354
+ # Refreshes the attributes for the entry with updated data from the
355
+ # domain controller.
356
+ #
357
+ def reload
358
+ return false if new_record?
359
+
360
+ @entry = @@ldap.search(:filter => Net::LDAP::Filter.eq('distinguishedName',distinguishedName))[0]
361
+ return !@entry.nil?
362
+ end
363
+
364
+ #
365
+ # Updates a single attribute (name) with one or more values
366
+ # (value), by immediately contacting the Active Directory
367
+ # server and initiating the update remotely.
368
+ #
369
+ # Entries are always reloaded (via Base.reload) after calling
370
+ # this method.
371
+ #
372
+ def update_attribute(name, value)
373
+ update_attributes(name.to_s => value)
374
+ end
375
+
376
+ #
377
+ # Updates multiple attributes, like ActiveRecord#update_attributes.
378
+ # The updates are immediately sent to the server for processing,
379
+ # and the entry is reloaded after the update (if all went well).
380
+ #
381
+ def update_attributes(attributes_to_update)
382
+ return true if attributes_to_update.empty?
383
+
384
+ operations = []
385
+ attributes_to_update.each do |attribute, values|
386
+ if values.nil? || values.empty?
387
+ operations << [ :delete, attribute, nil ]
388
+ else
389
+ values = [values] unless values.is_a? Array
390
+ values = values.collect { |v| v.to_s }
391
+
392
+ current_value = begin
393
+ @entry[attribute]
394
+ rescue NoMethodError
395
+ nil
396
+ end
397
+
398
+ operations << [ (current_value.nil? ? :add : :replace), attribute, values ]
399
+ end
400
+ end
401
+
402
+ @@ldap.modify(
403
+ :dn => distinguishedName,
404
+ :operations => operations
405
+ ) && reload
406
+ end
407
+
408
+ #
409
+ # Create a new entry in the Active Record store.
410
+ #
411
+ # dn is the Distinguished Name for the new entry. This must be
412
+ # a unique identifier, and can be passed as either a Container
413
+ # or a plain string.
414
+ #
415
+ # attributes is a symbol-keyed hash of attribute_name => value
416
+ # pairs.
417
+ #
418
+ def self.create(dn,attributes)
419
+ return nil if dn.nil? || attributes.nil?
420
+ begin
421
+ attributes.merge!(required_attributes)
422
+ if @@ldap.add(:dn => dn.to_s, :attributes => attributes)
423
+ return find_by_distinguishedName(dn.to_s)
424
+ else
425
+ return nil
426
+ end
427
+ rescue
428
+ return nil
429
+ end
430
+ end
431
+
432
+ #
433
+ # Deletes the entry from the Active Record store and returns true
434
+ # if the operation was successfully.
435
+ #
436
+ def destroy
437
+ return false if new_record?
438
+
439
+ if @@ldap.delete(:dn => distinguishedName)
440
+ @entry = nil
441
+ @attributes = {}
442
+ return true
443
+ else
444
+ return false
445
+ end
446
+ end
447
+
448
+ #
449
+ # Saves any pending changes to the entry by updating the remote
450
+ # entry.
451
+ #
452
+ def save
453
+ if update_attributes(@attributes)
454
+ @attributes = {}
455
+ return true
456
+ else
457
+ return false
458
+ end
459
+ end
460
+
461
+ #
462
+ # This method may one day provide the ability to move entries from
463
+ # container to container. Currently, it does nothing, as we are
464
+ # waiting on the Net::LDAP folks to either document the
465
+ # Net::LDAP#modrdn method, or provide a similar method for
466
+ # moving / renaming LDAP entries.
467
+ #
468
+ def move(new_rdn)
469
+ return false if new_record?
470
+ puts "Moving #{distinguishedName} to RDN: #{new_rdn}"
471
+
472
+ settings = @@settings.dup
473
+ settings[:port] = 636
474
+ settings[:encryption] = { :method => :simple_tls }
475
+
476
+ ldap = Net::LDAP.new(settings)
477
+
478
+ if ldap.rename(
479
+ :olddn => distinguishedName,
480
+ :newrdn => new_rdn,
481
+ :delete_attributes => false
482
+ )
483
+ return true
484
+ else
485
+ puts Base.error
486
+ return false
487
+ end
488
+ end
489
+
490
+ # FIXME: Need to document the Base::new
491
+ def initialize(attributes = {}) # :nodoc:
492
+ if attributes.is_a? Net::LDAP::Entry
493
+ @entry = attributes
494
+ @attributes = {}
495
+ else
496
+ @entry = nil
497
+ @attributes = attributes
498
+ end
499
+ end
500
+
501
+ ##
502
+ # Pull the class we're in
503
+ # This isn't quite right, as extending the object does funny things to how we
504
+ # lookup objects
505
+ def self.class_name
506
+ @klass ||= (self.name.include?('::') ? self.name[/.*::(.*)/, 1] : self.name)
507
+ end
508
+
509
+ ##
510
+ # Grabs the field type depending on the class it is called from
511
+ # Takes the field name as a parameter
512
+ def self.get_field_type(name)
513
+ #Extract class name
514
+ throw "Invalid field name" if name.nil?
515
+ type = ::Adtools.special_fields[class_name.to_sym][name.to_s.downcase.to_sym]
516
+ type.to_s unless type.nil?
517
+ end
518
+
519
+ @types = {}
520
+
521
+ def self.decode_field(name, value) # :nodoc:
522
+ type = get_field_type name
523
+ if !type.nil? and ::ActiveDirectory::FieldType::const_defined? type
524
+ return ::ActiveDirectory::FieldType::const_get(type).decode(value)
525
+ end
526
+ return value
527
+ end
528
+
529
+ def self.encode_field(name, value) # :nodoc:
530
+ type = get_field_type name
531
+ if !type.nil? and ::ActiveDirectory::FieldType::const_defined? type
532
+ return ::ActiveDirectory::FieldType::const_get(type).encode(value)
533
+ end
534
+ return value
535
+ end
536
+
537
+ def valid_attribute? name
538
+ @attributes.has_key?(name) || @entry.attribute_names.include?(name)
539
+ end
540
+
541
+ def get_attr(name)
542
+ name = name.to_s.downcase
543
+
544
+ return decode_field(name, @attributes[name.to_sym]) if @attributes.has_key?(name.to_sym)
545
+
546
+ if @entry.attribute_names.include? name.to_sym
547
+ value = @entry[name.to_sym]
548
+ value = value.first if value.kind_of?(Array) && value.size == 1
549
+ value = value.to_s if value.nil? || value.size == 1
550
+ value = nil.to_s if value.empty?
551
+ return self.class.decode_field(name, value)
552
+ end
553
+ end
554
+
555
+ def set_attr(name, value)
556
+ @attributes[name.to_sym] = encode_field(name, value)
557
+ end
558
+
559
+ ##
560
+ # Reads the array of values for the provided attribute. The attribute name
561
+ # is canonicalized prior to reading. Returns an empty array if the
562
+ # attribute does not exist.
563
+ alias [] get_attr
564
+ alias []= set_attr
565
+
566
+ ##
567
+ # Weird fluke with flattening, probably because of above attribute
568
+ def to_ary
569
+ end
570
+
571
+
572
+ def method_missing(name, args = []) # :nodoc:
573
+ name = name.to_s.downcase
574
+
575
+ return set_attr(name.chop, args) if name[-1] == '='
576
+
577
+ if valid_attribute? name.to_sym
578
+ get_attr(name)
579
+ else
580
+ super
581
+ end
582
+ end
583
+ end
584
+ end