chef-winrm-fs 1.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Author:: Fletcher (<fnichol@nichol.ca>)
5
+ #
6
+ # Copyright (C) 2015, Fletcher Nichol
7
+ #
8
+ # Licensed under the Apache License, Version 2.0 (the "License");
9
+ # you may not use this file except in compliance with the License.
10
+ # You may obtain a copy of the License at
11
+ #
12
+ # http://www.apache.org/licenses/LICENSE-2.0
13
+ #
14
+ # Unless required by applicable law or agreed to in writing, software
15
+ # distributed under the License is distributed on an "AS IS" BASIS,
16
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17
+ # See the License for the specific language governing permissions and
18
+ # limitations under the License.
19
+
20
+ require 'delegate'
21
+ require 'pathname' unless defined?(Pathname)
22
+ require 'tempfile' unless defined?(Tempfile)
23
+ require 'zip' unless defined?(Zip)
24
+
25
+ module WinRM
26
+ module FS
27
+ module Core
28
+ # A temporary Zip file for a given directory.
29
+ #
30
+ # @author Fletcher Nichol <fnichol@nichol.ca>
31
+ class TmpZip
32
+ # Contructs a new Zip file for the given directory.
33
+ #
34
+ # There are 2 ways to interpret the directory path:
35
+ #
36
+ # * If the directory has no path separator terminator, then the
37
+ # directory basename will be used as the base directory in the
38
+ # resulting zip file.
39
+ # * If the directory has a path separator terminator (such as `/` or
40
+ # `\\`), then the entries under the directory will be added to the
41
+ # resulting zip file.
42
+ #
43
+ # The following emaples assume a directory tree structure of:
44
+ #
45
+ # src
46
+ # |-- alpha.txt
47
+ # |-- beta.txt
48
+ # \-- sub
49
+ # \-- charlie.txt
50
+ #
51
+ # @example Including the base directory in the zip file
52
+ #
53
+ # TmpZip.new("/path/to/src")
54
+ # # produces a zip file with entries:
55
+ # # - src/alpha.txt
56
+ # # - src/beta.txt
57
+ # # - src/sub/charlie.txt
58
+ #
59
+ # @example Excluding the base directory in the zip file
60
+ #
61
+ # TmpZip.new("/path/to/src/")
62
+ # # produces a zip file with entries:
63
+ # # - alpha.txt
64
+ # # - beta.txt
65
+ # # - sub/charlie.txt
66
+ #
67
+ # @param dir [String,Pathname,#to_s] path to the directory
68
+ # @param logger [#debug,#debug?] an optional logger/ui object that
69
+ # responds to `#debug` and `#debug?` (default `nil`)
70
+ def initialize(dir, logger = nil)
71
+ @logger = logger || Logging.logger[self]
72
+ @dir = clean_dirname(dir)
73
+ @zip_io = Tempfile.open(['tmpzip-', '.zip'], binmode: true)
74
+ write_zip
75
+ @zip_io.close
76
+ end
77
+
78
+ # @return [Pathname] path to zip file
79
+ def path
80
+ Pathname.new(zip_io.path) if zip_io.path
81
+ end
82
+
83
+ # Unlinks (deletes) the zip file from the filesystem.
84
+ def unlink
85
+ zip_io.unlink
86
+ end
87
+
88
+ private
89
+
90
+ # @return [Pathname] the directory used to create the Zip file
91
+ # @api private
92
+ attr_reader :dir
93
+
94
+ # @return [#debug] the logger
95
+ # @api private
96
+ attr_reader :logger
97
+
98
+ # @return [IO] the Zip file IO
99
+ # @api private
100
+ attr_reader :zip_io
101
+
102
+ # @return [Pathname] the pathname object representing dirname that
103
+ # doesn't have any of those ~ in it
104
+ # @api private
105
+ def clean_dirname(dir)
106
+ paths = Pathname.glob(dir)
107
+ raise "Expected Pathname.glob(dir) to return only dir, got #{paths}" if paths.length != 1
108
+
109
+ paths.first
110
+ end
111
+
112
+ # @return [Array<Pathname] all recursive files under the base
113
+ # directory, excluding directories
114
+ # @api private
115
+ def entries
116
+ Pathname.glob(dir.join('**/.*')).push(*Pathname.glob(dir.join('**/*'))).delete_if(&:directory?).sort
117
+ end
118
+
119
+ # (see Logging.log_subject)
120
+ # @api private
121
+ def log_subject
122
+ @log_subject ||= [self.class.to_s.split('::').last, path].join('::')
123
+ end
124
+
125
+ # Adds all file entries to the Zip output stream.
126
+ #
127
+ # @param zos [Zip::OutputStream] zip output stream
128
+ # @api private
129
+ def produce_zip_entries(zos)
130
+ entries.each do |entry|
131
+ entry_path = entry.relative_path_from(dir)
132
+ logger.debug "+++ Adding #{entry_path}"
133
+ zos.put_next_entry(
134
+ zip_entry(entry_path),
135
+ nil, nil, ::Zip::Entry::DEFLATED, Zlib::BEST_COMPRESSION
136
+ )
137
+ entry.open('rb') { |src| IO.copy_stream(src, zos) }
138
+ end
139
+ logger.debug '=== All files added.'
140
+ end
141
+
142
+ # Writes out a temporary Zip file.
143
+ #
144
+ # @api private
145
+ def write_zip
146
+ logger.debug 'Populating files'
147
+ Zip::OutputStream.write_buffer(NoDupIO.new(zip_io)) do |zos|
148
+ produce_zip_entries(zos)
149
+ end
150
+ end
151
+
152
+ def zip_entry(entry_path)
153
+ Zip::Entry.new(
154
+ zip_io.path,
155
+ entry_path.to_s,
156
+ nil, nil, nil, nil, nil, nil,
157
+ ::Zip::DOSTime.new(2000)
158
+ )
159
+ end
160
+
161
+ # Simple delegate wrapper to prevent `#dup` calls being made on IO
162
+ # objects. This is used to bypass an issue in the `Zip::Outputstream`
163
+ # constructor where an incoming IO is duplicated, leading to races
164
+ # on flushing the final stream to disk.
165
+ #
166
+ # @author Fletcher Nichol <fnichol@nichol.ca>
167
+ # @api private
168
+ class NoDupIO < SimpleDelegator
169
+ # @return [self] returns self and does *not* return a duplicate
170
+ # object
171
+ def dup
172
+ self
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright 2015 Shawn Neal <sneal@sneal.net>
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ module WinRM
19
+ module FS
20
+ # WinRM-FS base class for errors
21
+ class WinRMFSError < StandardError; end
22
+
23
+ # Error that occurs when a file upload fails
24
+ class WinRMUploadError < WinRMFSError; end
25
+
26
+ # Error that occurs when a file download fails
27
+ class WinRMDownloadError < WinRMFSError; end
28
+ end
29
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright 2015 Shawn Neal <sneal@sneal.net>
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ require 'winrm' unless defined?(WinRM::Connection)
19
+ require_relative 'scripts/scripts'
20
+ require_relative 'core/file_transporter'
21
+
22
+ module WinRM
23
+ module FS
24
+ # Perform file transfer operations between a local machine and winrm endpoint
25
+ class FileManager
26
+ # Creates a new FileManager instance
27
+ # @param [WinRM::Connection] WinRM web connection client
28
+ def initialize(connection)
29
+ @connection = connection
30
+ @logger = connection.logger
31
+ end
32
+
33
+ # Gets the SHA1 checksum of the specified file if it exists,
34
+ # otherwise ''
35
+ # @param [String] The remote file path
36
+ # @parms [String] The digest method
37
+ def checksum(path, digest = 'SHA1')
38
+ @logger.debug("checksum with #{digest}: #{path}")
39
+ script = WinRM::FS::Scripts.render('checksum', path: path, digest: digest)
40
+ ps_run(script).exitcode == 0
41
+ end
42
+
43
+ # Create the specifed directory recursively
44
+ # @param [String] The remote dir to create
45
+ # @return [Boolean] True if successful, otherwise false
46
+ def create_dir(path)
47
+ @logger.debug("create_dir: #{path}")
48
+ script = WinRM::FS::Scripts.render('create_dir', path: path)
49
+ ps_run(script).exitcode == 0
50
+ end
51
+
52
+ # Deletes the file or directory at the specified path
53
+ # @param [String] The path to remove
54
+ # @return [Boolean] True if successful, otherwise False
55
+ def delete(path)
56
+ @logger.debug("deleting: #{path}")
57
+ script = WinRM::FS::Scripts.render('delete', path: path)
58
+ ps_run(script).exitcode == 0
59
+ end
60
+
61
+ # Downloads the specified remote file to the specified local path
62
+ # @param [String] The full path on the remote machine
63
+ # @param [String] The full path to write the file to locally
64
+ # rubocop:disable Metrics/MethodLength
65
+ def download(remote_path, local_path, chunk_size = 1024 * 1024, first = true)
66
+ @logger.debug("downloading: #{remote_path} -> #{local_path} #{chunk_size}")
67
+ index = 0
68
+ output = _output_from_file(remote_path, chunk_size, index)
69
+ return download_dir(remote_path, local_path, chunk_size, first) if output.exitcode == 2
70
+
71
+ return false if output.exitcode >= 1
72
+
73
+ File.open(local_path, 'wb') do |fd|
74
+ out = _write_file(fd, output)
75
+ index += out.length
76
+ until out.empty?
77
+ output = _output_from_file(remote_path, chunk_size, index)
78
+ return false if output.exitcode >= 1
79
+
80
+ out = _write_file(fd, output)
81
+ index += out.length
82
+ end
83
+ end
84
+ true
85
+ end
86
+ # rubocop:enable Metrics/MethodLength
87
+
88
+ def _output_from_file(remote_path, chunk_size, index)
89
+ script = WinRM::FS::Scripts.render('download', path: remote_path, chunk_size: chunk_size, index: index)
90
+ ps_run(script)
91
+ end
92
+
93
+ def _write_file(tofd, output)
94
+ contents = output.stdout.gsub('\n\r', '')
95
+ out = Base64.decode64(contents)
96
+ out = out[0, out.length - 1] if out.end_with? "\x00"
97
+ return out if out.empty?
98
+
99
+ tofd.write(out)
100
+ out
101
+ end
102
+
103
+ # Checks to see if the given path exists on the target file system.
104
+ # @param [String] The full path to the directory or file
105
+ # @return [Boolean] True if the file/dir exists, otherwise false.
106
+ def exists?(path)
107
+ @logger.debug("exists?: #{path}")
108
+ script = WinRM::FS::Scripts.render('exists', path: path)
109
+ ps_run(script).exitcode == 0
110
+ end
111
+
112
+ # Gets the current user's TEMP directory on the remote system, for example
113
+ # 'C:/Windows/Temp'
114
+ # @return [String] Full path to the temp directory
115
+ def temp_dir
116
+ @temp_dir ||= begin
117
+ ps_run('$env:TEMP').stdout.chomp.tr('\\', '/')
118
+ end
119
+ end
120
+
121
+ # Upload one or more local files and directories to a remote directory
122
+ # @example copy a single file to a winrm endpoint
123
+ #
124
+ # file_manager.upload('/Users/sneal/myfile.txt', 'c:/foo/myfile.txt')
125
+ #
126
+ # @example copy a single directory to a winrm endpoint
127
+ #
128
+ # file_manager.upload('c:/dev/my_dir', '$env:AppData')
129
+ #
130
+ # @param [String] A path to a local directory or file that will be copied
131
+ # to the remote Windows box.
132
+ # @param [String] The target directory or file
133
+ # This path may contain powershell style environment variables
134
+ # @yieldparam [Fixnum] Number of bytes copied in current payload sent to the winrm endpoint
135
+ # @yieldparam [Fixnum] The total number of bytes to be copied
136
+ # @yieldparam [String] Path of file being copied
137
+ # @yieldparam [String] Target path on the winrm endpoint
138
+ # @return [Fixnum] The total number of bytes copied
139
+ def upload(local_path, remote_path, &block)
140
+ @connection.shell(:powershell) do |shell|
141
+ file_transporter ||= WinRM::FS::Core::FileTransporter.new(shell)
142
+ begin
143
+ file_transporter.upload(local_path, remote_path, &block)[0]
144
+ ensure
145
+ file_transporter.close
146
+ end
147
+ end
148
+ end
149
+
150
+ private
151
+
152
+ def ps_run(cmd)
153
+ shell = @connection.shell(:powershell)
154
+ begin
155
+ shell.run(cmd)
156
+ ensure
157
+ shell.close
158
+ end
159
+ end
160
+
161
+ def download_dir(remote_path, local_path, chunk_size, first)
162
+ local_path = File.join(local_path, File.basename(remote_path.to_s)) if first
163
+ FileUtils.mkdir_p(local_path) unless File.directory?(local_path)
164
+ command = "Get-ChildItem #{remote_path} | Select-Object Name"
165
+ @connection.shell(:powershell) { |e| e.run(command) }.stdout.strip.split(/\n/).drop(2).each do |file|
166
+ download(File.join(remote_path.to_s, file.strip), File.join(local_path, file.strip), chunk_size, false)
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,49 @@
1
+ $hash_file = <%= hash_file %>
2
+
3
+ Function Cleanup($disposable) {
4
+ if (($disposable -ne $null) -and ($disposable.GetType().GetMethod("Dispose") -ne $null)) {
5
+ $disposable.Dispose()
6
+ }
7
+ }
8
+
9
+ Function Check-Files($h) {
10
+ return $h.GetEnumerator() | ForEach-Object {
11
+ $dst = Unresolve-Path $_.Value.target
12
+ $dst_changed = $false
13
+ if(Test-Path $dst -PathType Container) {
14
+ $dst_changed = $true
15
+ $dst = Join-Path $dst $_.Value.src_basename
16
+ }
17
+ New-Object psobject -Property @{
18
+ chk_exists = ($exists = Test-Path $dst -PathType Leaf)
19
+ src_sha1 = ($sMd5 = $_.Key)
20
+ dst_sha1 = ($dMd5 = if ($exists) { Get-SHA1Sum $dst } else { $null })
21
+ chk_dirty = ($dirty = if ($sMd5 -ne $dMd5) { $true } else { $false })
22
+ verifies = if ($dirty -eq $false) { $true } else { $false }
23
+ target_is_folder = $dst_changed
24
+ }
25
+ } | Select-Object -Property chk_exists,src_sha1,dst_sha1,chk_dirty,verifies,target_is_folder
26
+ }
27
+
28
+ Function Get-SHA1Sum($src) {
29
+ Try {
30
+ $c = [System.Security.Cryptography.SHA1]::Create()
31
+ $bytes = $c.ComputeHash(($in = (Get-Item $src).OpenRead()))
32
+ return ([System.BitConverter]::ToString($bytes)).Replace("-", "").ToLower()
33
+ }
34
+ Finally {
35
+ Cleanup $c
36
+ Cleanup $in
37
+ }
38
+ }
39
+
40
+ Function Unresolve-Path($path) {
41
+ if ($path -eq $null) {
42
+ return $null
43
+ }
44
+ else {
45
+ return $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($path)
46
+ }
47
+ }
48
+
49
+ Check-Files $hash_file | ConvertTo-Csv -NoTypeInformation
@@ -0,0 +1,13 @@
1
+ $p = $ExecutionContext.SessionState.Path
2
+ $path = $p.GetUnresolvedProviderPathFromPSPath("<%= path %>")
3
+
4
+ if (Test-Path $path -PathType Leaf) {
5
+ $cryptoProv = [System.Security.Cryptography.<%= digest %>]::Create()
6
+ $file = [System.IO.File]::Open($path,
7
+ [System.IO.Filemode]::Open, [System.IO.FileAccess]::Read)
8
+ $digest = ([System.BitConverter]::ToString($cryptoProv.ComputeHash($file)))
9
+ $digest = $digest.Replace("-","").ToLower()
10
+ $file.Close()
11
+
12
+ Write-Output $digest
13
+ }
@@ -0,0 +1,6 @@
1
+ $p = $ExecutionContext.SessionState.Path
2
+ $path = $p.GetUnresolvedProviderPathFromPSPath("<%= path %>")
3
+ if (!(Test-Path $path)) {
4
+ New-Item -ItemType Directory -Force -Path $path | Out-Null
5
+ }
6
+ exit 0
@@ -0,0 +1,6 @@
1
+ $p = $ExecutionContext.SessionState.Path
2
+ $path = $p.GetUnresolvedProviderPathFromPSPath("<%= path %>")
3
+ if (Test-Path $path) {
4
+ Remove-Item $path -Force -Recurse
5
+ }
6
+ exit 0
@@ -0,0 +1,17 @@
1
+ $p = $ExecutionContext.SessionState.Path
2
+ $path = $p.GetUnresolvedProviderPathFromPSPath("<%= path %>")
3
+ $index = <%= index %>
4
+ $chunkSize = <%= chunk_size %>
5
+ if (Test-Path $path -PathType Leaf) {
6
+ $file = [System.IO.File]::OpenRead($path)
7
+ $seekedTo = $file.Seek($index, [System.IO.SeekOrigin]::Begin)
8
+ $chunk = New-Object byte[] $chunkSize
9
+ $bytesRead = $file.Read($chunk, 0, $chunkSize)
10
+ $bytes = [System.convert]::ToBase64String($chunk[0..$bytesRead])
11
+ Write-Host $bytes
12
+ exit 0
13
+ }
14
+ ElseIf (Test-Path $path -PathType Container) {
15
+ exit 2
16
+ }
17
+ exit 1
@@ -0,0 +1,10 @@
1
+ $p = $ExecutionContext.SessionState.Path
2
+ $path = $p.GetUnresolvedProviderPathFromPSPath("<%= path %>")
3
+ if (Test-Path $path) {
4
+ $true
5
+ exit 0
6
+ }
7
+ else {
8
+ $false
9
+ exit 1
10
+ }
@@ -0,0 +1,52 @@
1
+ trap {
2
+ $e = $_.Exception
3
+ $e.InvocationInfo.ScriptName
4
+ do {
5
+ $e.Message
6
+ $e = $e.InnerException
7
+ } while ($e)
8
+ break
9
+ }
10
+
11
+ function folder($path){
12
+ $path | ? {-not (test-path $_)} | % {$null = mkdir $_}
13
+ }
14
+
15
+ Function Decode-Files($hash) {
16
+ foreach ($key in $hash.keys) {
17
+ $value = $hash[$key]
18
+ $tzip, $dst = $Value["tmpzip"], $Value["dst"]
19
+ if ($tzip) {Unzip-File $tzip $dst}
20
+ New-Object psobject -Property @{dst=$dst;src_sha1=$key;tmpzip=$tzip}
21
+ }
22
+ }
23
+
24
+ Function Unzip-File($src, $dst) {
25
+ $unpack = $src -replace '\.zip'
26
+ $dst_parent = Split-Path -Path $dst -Parent
27
+ if(!(Test-Path $dst_parent)) { $dst = $dst_parent }
28
+ folder $unpack, $dst
29
+ try {
30
+ try{
31
+ [IO.Compression.ZipFile]::ExtractToDirectory($src, $unpack)
32
+ }
33
+ catch {
34
+ Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Stop
35
+ [IO.Compression.ZipFile]::ExtractToDirectory($src, $unpack)
36
+ }
37
+ }
38
+ catch {
39
+ Try {
40
+ $s = New-Object -ComObject Shell.Application
41
+ ($s.NameSpace($unpack)).CopyHere(($s.NameSpace($src)).Items(), 0x610) | Out-Null
42
+ }
43
+ Finally {
44
+ [void][Runtime.Interopservices.Marshal]::ReleaseComObject($s)
45
+ }
46
+ }
47
+ dir $unpack | cp -dest "$dst/" -force -recurse
48
+ rm $unpack -recurse -force
49
+ }
50
+
51
+ $hash_file = <%= hash_file %>
52
+ Decode-Files $hash_file | ConvertTo-Csv -NoTypeInformation
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright 2015 Shawn Neal <sneal@sneal.net>
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ require 'erubi'
19
+
20
+ module WinRM
21
+ module FS
22
+ # PS1 scripts
23
+ module Scripts
24
+ # rubocop:disable Metrics/MethodLength
25
+ def self.render(template, context)
26
+ # rubocop:enable Metrics/MethodLength
27
+ template_path = File.expand_path(
28
+ "#{File.dirname(__FILE__)}/#{template}.ps1.erb"
29
+ )
30
+ template = File.read(template_path)
31
+ case context
32
+ when Hash
33
+ b = binding
34
+ locals = context.collect { |k, _| "#{k} = context[#{k.inspect}]; " }
35
+ b.eval(locals.join)
36
+ when Binding
37
+ b = context
38
+ when NilClass
39
+ b = binding
40
+ else
41
+ raise ArgumentError
42
+ end
43
+ b.eval(Erubi::Engine.new(template).src)
44
+ end
45
+ end
46
+ end
47
+ end
data/lib/winrm-fs.rb ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright 2015 Shawn Neal <sneal@sneal.net>
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ require 'winrm' unless defined?(WinRM::Connection)
19
+ require 'logger'
20
+ require 'pathname' unless defined?(Pathname)
21
+ require_relative 'winrm-fs/exceptions'
22
+ require_relative 'winrm-fs/file_manager'
23
+
24
+ module WinRM
25
+ # WinRM File System
26
+ module FS
27
+ # Top level module code
28
+ end
29
+ end