diff options
| author | Dan Allen <dan.j.allen@gmail.com> | 2013-04-25 12:34:12 -0700 |
|---|---|---|
| committer | Dan Allen <dan.j.allen@gmail.com> | 2013-04-25 12:34:12 -0700 |
| commit | db3659b1b458f16f5d37d6002147fce441cd9209 (patch) | |
| tree | abe9b5178cab1b16a489b3c37968e3ef6ae8e3fc /lib | |
| parent | 666a4f71ecbae62f8b98c69411e22bce59be14a9 (diff) | |
| parent | 51b14a34ecb0178c914ced0f653b92adfb321713 (diff) | |
Merge pull request #276 from mojavelinux/default-stylesheet
resolves #76 add a default stylesheet
Diffstat (limited to 'lib')
| -rwxr-xr-x | lib/asciidoctor.rb | 99 | ||||
| -rw-r--r-- | lib/asciidoctor/abstract_node.rb | 161 | ||||
| -rw-r--r-- | lib/asciidoctor/backends/html5.rb | 19 | ||||
| -rw-r--r-- | lib/asciidoctor/cli/invoker.rb | 72 | ||||
| -rw-r--r-- | lib/asciidoctor/cli/options.rb | 6 | ||||
| -rw-r--r-- | lib/asciidoctor/document.rb | 42 | ||||
| -rw-r--r-- | lib/asciidoctor/errors.rb | 5 | ||||
| -rw-r--r-- | lib/asciidoctor/path_resolver.rb | 360 | ||||
| -rw-r--r-- | lib/asciidoctor/reader.rb | 2 |
9 files changed, 605 insertions, 161 deletions
diff --git a/lib/asciidoctor.rb b/lib/asciidoctor.rb index 6f599e88..acd3493a 100755 --- a/lib/asciidoctor.rb +++ b/lib/asciidoctor.rb @@ -88,6 +88,9 @@ module Asciidoctor end + # The root path of the Asciidoctor gem + ROOT_PATH = File.expand_path(File.join(File.dirname(__FILE__), '..')) + # The default document type # Can influence markup generated by render templates DEFAULT_DOCTYPE = 'article' @@ -95,6 +98,12 @@ module Asciidoctor # The backend determines the format of the rendered output, default to html5 DEFAULT_BACKEND = 'html5' + DEFAULT_STYLESHEET_PATH = File.join(ROOT_PATH, 'stylesheets', 'asciidoctor.css') + + DEFAULT_STYLESHEET_KEYS = ['', 'DEFAULT'].to_set + + DEFAULT_STYLESHEET_NAME = File.basename(DEFAULT_STYLESHEET_PATH) + # Pointers to the preferred version for a given backend. BACKEND_ALIASES = { 'html' => 'html5', @@ -661,6 +670,10 @@ module Asciidoctor # # returns the Asciidoctor::Document def self.load(input, options = {}, &block) + if (monitor = options.fetch(:monitor, false)) + start = Time.now + end + lines = nil if input.is_a?(File) options[:attributes] ||= {} @@ -686,7 +699,19 @@ module Asciidoctor raise "Unsupported input type: #{input.class}" end - Document.new(lines, options, &block) + if monitor + read_time = Time.now - start + start = Time.now + end + + doc = Document.new(lines, options, &block) + if monitor + parse_time = Time.now - start + monitor[:read] = read_time + monitor[:parse] = parse_time + monitor[:load] = read_time + parse_time + end + doc end # Public: Parse the contents of the AsciiDoc source file into an Asciidoctor::Document @@ -732,18 +757,22 @@ module Asciidoctor # see Asciidoctor::Document#initialize for details # block - a callback block for handling include::[] directives # - # returns nothing if the rendered output String is written to a file, - # otherwise the rendered output String is returned + # returns the Document object if the rendered result String is written to a + # file, otherwise the rendered result String def self.render(input, options = {}, &block) in_place = options.delete(:in_place) || false to_file = options.delete(:to_file) to_dir = options.delete(:to_dir) mkdirs = options.delete(:mkdirs) || false + monitor = options.fetch(:monitor, false) write_in_place = in_place && input.is_a?(File) write_to_target = to_file || to_dir + stream_output = !to_file.nil? && to_file.respond_to?(:write) - raise ArgumentError, ':in_place with input file must not accompany :to_dir or :to_file' if write_in_place && write_to_target + if write_in_place && write_to_target + raise ArgumentError, 'the option :in_place cannot be used with either the :to_dir or :to_file option' + end if !options.has_key?(:header_footer) && (write_in_place || write_to_target) options[:header_footer] = true @@ -751,21 +780,26 @@ module Asciidoctor doc = Asciidoctor.load(input, options, &block) - if write_in_place + if to_file == '/dev/null' + return doc + elsif write_in_place to_file = File.join(File.dirname(input.path), "#{doc.attributes['docname']}#{doc.attributes['outfilesuffix']}") - elsif write_to_target + elsif !stream_output && write_to_target + working_dir = options.has_key?(:base_dir) ? File.expand_path(opts[:base_dir]) : File.expand_path(Dir.pwd) + # QUESTION should the jail be the working_dir or doc.base_dir??? + jail = doc.safe >= SafeMode::SAFE ? working_dir : nil if to_dir - to_dir = doc.normalize_asset_path(to_dir, 'to_dir', false) + to_dir = doc.normalize_system_path(to_dir, working_dir, jail, :target_name => 'to_dir', :recover => false) if to_file - # normalize again, to_file could have dirty bits - to_file = doc.normalize_asset_path(File.expand_path(to_file, to_dir), 'to_file', false) + to_file = doc.normalize_system_path(to_file, to_dir, nil, :target_name => 'to_dir', :recover => false) # reestablish to_dir as the final target directory (in the case to_file had directory segments) to_dir = File.dirname(to_file) else to_file = File.join(to_dir, "#{doc.attributes['docname']}#{doc.attributes['outfilesuffix']}") end elsif to_file - to_file = doc.normalize_asset_path(to_file, 'to_file', false) + to_file = doc.normalize_system_path(to_file, working_dir, jail, :target_name => 'to_dir', :recover => false) + # establish to_dir as the final target directory (in the case to_file had directory segments) to_dir = File.dirname(to_file) end @@ -779,11 +813,44 @@ module Asciidoctor end end + start = Time.now if monitor + output = doc.render + + if monitor + render_time = Time.now - start + monitor[:render] = render_time + monitor[:load_render] = monitor[:load] + render_time + end + if to_file - File.open(to_file, 'w') {|file| file.write doc.render } - nil + start = Time.now if monitor + if stream_output + to_file.write output + else + File.open(to_file, 'w') {|file| file.write output } + # these assignments primarily for testing, diagnostics or reporting + doc.attributes['outfile'] = outfile = File.expand_path(to_file) + doc.attributes['outdir'] = File.dirname(outfile) + end + if monitor + write_time = Time.now - start + monitor[:write] = write_time + monitor[:total] = monitor[:load_render] + write_time + end + + # NOTE document cannot control this behavior if safe >= SafeMode::SERVER + if !stream_output && doc.attr?('copycss') && + doc.attr?('linkcss') && DEFAULT_STYLESHEET_KEYS.include?(doc.attr('stylesheet')) + Helpers.require_library 'fileutils' + outdir = doc.attr('outdir') + stylesdir = doc.normalize_system_path(doc.attr('stylesdir'), outdir, + doc.safe >= SafeMode::SAFE ? outdir : nil) + FileUtils.mkdir_p stylesdir + FileUtils.cp DEFAULT_STYLESHEET_PATH, stylesdir, :preserve => true + end + doc else - doc.render + output end end @@ -795,8 +862,8 @@ module Asciidoctor # see Asciidoctor::Document#initialize for details # block - a callback block for handling include::[] directives # - # returns nothing if the rendered output String is written to a file, - # otherwise the rendered output String is returned + # returns the Document object if the rendered result String is written to a + # file, otherwise the rendered result String def self.render_file(filename, options = {}, &block) Asciidoctor.render(File.new(filename), options, &block) end @@ -824,10 +891,10 @@ module Asciidoctor require 'asciidoctor/block' require 'asciidoctor/callouts' require 'asciidoctor/document' - #require 'asciidoctor/errors' require 'asciidoctor/inline' require 'asciidoctor/lexer' require 'asciidoctor/list_item' + require 'asciidoctor/path_resolver' require 'asciidoctor/reader' require 'asciidoctor/renderer' require 'asciidoctor/section' diff --git a/lib/asciidoctor/abstract_node.rb b/lib/asciidoctor/abstract_node.rb index 84ab5757..d9d0c04b 100644 --- a/lib/asciidoctor/abstract_node.rb +++ b/lib/asciidoctor/abstract_node.rb @@ -177,7 +177,7 @@ class AbstractNode if attr? 'icon' image_uri(attr('icon'), nil) else - image_uri(name + '.' + @document.attr('icontype', 'png'), 'iconsdir') + image_uri("#{name}.#{@document.attr('icontype', 'png')}", 'iconsdir') end end @@ -199,9 +199,9 @@ class AbstractNode if target.include?(':') && target.match(Asciidoctor::REGEXP[:uri_sniff]) target elsif asset_dir_key && attr?(asset_dir_key) - File.join(@document.attr(asset_dir_key), target) + normalize_web_path(target, @document.attr(asset_dir_key)) else - target + normalize_web_path(target) end end @@ -230,9 +230,9 @@ class AbstractNode elsif @document.safe < Asciidoctor::SafeMode::SECURE && @document.attr?('data-uri') generate_data_uri(target_image, asset_dir_key) elsif asset_dir_key && attr?(asset_dir_key) - File.join(@document.attr(asset_dir_key), target_image) + normalize_web_path(target_image, @document.attr(asset_dir_key)) else - target_image + normalize_web_path(target_image) end end @@ -253,9 +253,17 @@ class AbstractNode mimetype = 'image/' + File.extname(target_image)[1..-1] if asset_dir_key - image_path = File.join(normalize_asset_path(@document.attr(asset_dir_key, '.'), asset_dir_key), target_image) + #asset_dir_path = normalize_system_path(@document.attr(asset_dir_key), nil, nil, :target_name => asset_dir_key) + #image_path = normalize_system_path(target_image, asset_dir_path, nil, :target_name => 'image') + image_path = normalize_system_path(target_image, @document.attr(asset_dir_key), nil, :target_name => 'image') else - image_path = normalize_asset_path(target_image) + image_path = normalize_system_path(target_image) + end + + if !File.readable? image_path + puts "asciidoctor: WARNING: image to embed not found or not readable: #{image_path}" + return "data:#{mimetype}:base64," + #return 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==' end bindata = nil @@ -264,88 +272,79 @@ class AbstractNode else bindata = File.open(image_path, 'rb') {|file| file.read } end - 'data:' + mimetype + ';base64,' + Base64.encode64(bindata).delete("\n") + "data:#{mimetype};base64,#{Base64.encode64(bindata).delete("\n")}" end - # Public: Normalize the asset file or directory to a concrete and rinsed path + # Public: Read the contents of the file at the specified path. + # This method assumes that the path is safe to read. It checks + # that the file is readable before attempting to read it. # - # The most important functionality in this method is to prevent the asset - # reference from resolving to a directory outside of the chroot directory - # (which defaults to the directory of the source file, stored in the base_dir - # instance variable on Document) if the document safe level is set to - # SafeMode::SAFE or greater (a condition which is true by default). - # - # asset_ref - the String asset file or directory referenced in the document - # or configuration attribute - # asset_name - the String name of the file or directory being resolved (for use in - # the warning message) (default: 'path') - # - # Examples - # - # # given these fixtures - # document.base_dir - # # => "/path/to/chroot" - # document.safe >= Asciidoctor::SafeMode::SAFE - # # => true - # - # # then - # normalize_asset_path('images') - # # => "/path/to/chroot/images" - # normalize_asset_path('/etc/images') - # # => "/path/to/chroot/images" - # normalize_asset_path('../images') - # # => "/path/to/chroot/images" - # - # # given these fixtures - # document.base_dir - # # => "/path/to/chroot" - # document.safe >= Asciidoctor::SafeMode::SAFE - # # => false - # - # # then - # normalize_asset_path('images') - # # => "/path/to/chroot/images" - # normalize_asset_path('/etc/images') - # # => "/etc/images" - # normalize_asset_path('../images') - # # => "/path/to/images" - # - # Returns The normalized asset file or directory as a String path - #-- - # TODO this method is missing a coordinate; it should be able to resolve - # both the directory reference and the path to an asset in it; callers - # of this method are still doing a File.join to finish the task - def normalize_asset_path(asset_ref, asset_name = 'path', autocorrect = true) - # TODO we may use pathname enough to make it a top-level require - Helpers.require_library 'pathname' - - input_path = @document.base_dir - asset_path = Pathname.new(asset_ref) - - if asset_path.relative? - asset_path = File.expand_path(File.join(input_path, asset_ref)) + # path - the String path from which to read the contents + # + # returns the contents of the file at the specified path, or nil + # if the file does not exist. + def read_asset(path) + if File.readable? path + File.read path else - asset_path = asset_path.cleanpath.to_s + nil end + end - if @document.safe >= SafeMode::SAFE - relative_asset_path = Pathname.new(asset_path).relative_path_from(Pathname.new(input_path)).to_s - if relative_asset_path.start_with?('..') - if autocorrect - puts "asciidoctor: WARNING: #{asset_name} has illegal reference to ancestor of base directory" - else - raise SecurityError, "#{asset_name} has reference to path outside of base directory, disallowed in safe mode: #{asset_path}" - end - relative_asset_path.sub!(REGEXP[:leading_parent_dirs], '') - # just to be absolutely sure ;) - if relative_asset_path[0..0] == '.' - raise 'Substitution of parent path references failed for ' + relative_asset_path - end - asset_path = File.expand_path(File.join(input_path, relative_asset_path)) - end + # Public: Normalize the web page using the PathResolver. + # + # See PathResolver::web_path(target, start) for details. + # + # target - the String target path + # start - the String start (i.e, parent) path (optional, default: nil) + # + # returns the resolved String path + def normalize_web_path(target, start = nil) + PathResolver.new.web_path(target, start) + end + + # Public: Resolve and normalize a secure path from the target and start paths + # using the PathResolver. + # + # See PathResolver::system_path(target, start, jail, opts) for details. + # + # The most important functionality in this method is to prevent resolving a + # path outside of the jail (which defaults to the directory of the source + # file, stored in the base_dir instance variable on Document) if the document + # safe level is set to SafeMode::SAFE or greater (a condition which is true + # by default). + # + # target - the String target path + # start - the String start (i.e., parent) path + # jail - the String jail path to confine the resolved path + # opts - an optional Hash of options to control processing (default: {}): + # * :recover is used to control whether the processor should auto-recover + # when an illegal path is encountered + # * :target_name is used in messages to refer to the path being resolved + # + # raises a SecurityError if a jail is specified and the resolved path is + # outside the jail. + # + # returns a String path resolved from the start and target paths, with any + # parent references resolved and self references removed. If a jail is provided, + # this path will be guaranteed to be contained within the jail. + def normalize_system_path(target, start = nil, jail = nil, opts = {}) + if start.nil? + start = @document.base_dir end + if jail.nil? && @document.safe >= SafeMode::SAFE + jail = @document.base_dir + end + PathResolver.new.system_path(target, start, jail, opts) + end - asset_path + # Public: Normalize the asset file or directory to a concrete and rinsed path + # + # Delegates to normalize_system_path, with the start path set to the value of + # the base_dir instance variable on the Document object. + def normalize_asset_path(asset_ref, asset_name = 'path', autocorrect = true) + normalize_system_path(asset_ref, @document.base_dir, nil, + :target_name => asset_name, :recover => autocorrect) end end diff --git a/lib/asciidoctor/backends/html5.rb b/lib/asciidoctor/backends/html5.rb index 5dd0a154..84f5d18b 100644 --- a/lib/asciidoctor/backends/html5.rb +++ b/lib/asciidoctor/backends/html5.rb @@ -71,8 +71,22 @@ class DocumentTemplate < BaseTemplate <meta name="keywords" content="<%= attr :keywords %>"> <% end %> <title><%= doctitle %></title> - <% unless attr(:stylesheet, '').empty? %> - <link rel="stylesheet" href="<%= (attr? :stylesdir) ? File.join((attr :stylesdir), (attr :stylesheet)) : (attr :stylesheet) %>"> + <% if DEFAULT_STYLESHEET_KEYS.include?(attr 'stylesheet') %> + <% if attr? 'linkcss' %> + <link rel="stylesheet" href="<%= normalize_web_path(DEFAULT_STYLESHEET_NAME, (attr :stylesdir, '')) %>"> + <% else %> + <style> +<%= read_asset DEFAULT_STYLESHEET_PATH %> + </style> + <% end %> + <% elsif attr? :stylesheet %> + <% if attr? 'linkcss' %> + <link rel="stylesheet" href="<%= normalize_web_path(attr(:stylesdir, ''), (attr :stylesheet)) %>"> + <% else %> + <style> +<%= read_asset normalize_system_path(attr(:stylesheet), attr(:stylesdir, '')) %> + </style> + <% end %> <% end %> <% case attr 'source-highlighter' %><% when 'coderay' %> @@ -151,7 +165,6 @@ class EmbeddedTemplate < BaseTemplate end class BlockTocTemplate < BaseTemplate - # id must be unique if this macro is used > once or when built-in one is present def result(node) doc = node.document diff --git a/lib/asciidoctor/cli/invoker.rb b/lib/asciidoctor/cli/invoker.rb index 3b1b3e30..7ef5ec75 100644 --- a/lib/asciidoctor/cli/invoker.rb +++ b/lib/asciidoctor/cli/invoker.rb @@ -5,14 +5,12 @@ module Asciidoctor attr_reader :options attr_reader :document attr_reader :code - attr_reader :timings def initialize(*options) @document = nil @out = nil @err = nil @code = 0 - @timings = {} options = options.flatten if !options.empty? && options.first.is_a?(Cli::Options) @options = options.first @@ -32,46 +30,52 @@ module Asciidoctor return if @options.nil? begin - @timings = {} - infile = @options[:input_file] - outfile = @options[:output_file] + opts = {} + monitor = {} + infile = nil + outfile = nil + @options.map {|k, v| + case k + when :input_file + infile = v + when :output_file + outfile = v + when :destination_dir + #opts[:to_dir] = File.expand_path(v) unless v.nil? + opts[:to_dir] = v unless v.nil? + when :attributes + opts[:attributes] = v.dup + when :verbose + opts[:monitor] = monitor if v + when :trace + # currently, nothing + else + opts[k] = v unless v.nil? + end + } + if infile == '-' # allow use of block to supply stdin, particularly useful for tests input = block_given? ? yield : STDIN else input = File.new(infile) end - start = Time.now - @document = Asciidoctor.load(input, @options) - timings[:parse] = Time.now - start - start = Time.now - output = @document.render - timings[:render] = Time.now - start - if @options[:verbose] - puts "Time to read and parse source: #{timings[:parse]}" - puts "Time to render document: #{timings[:render]}" - puts "Total time to read, parse and render: #{timings.reduce(0) {|sum, (_, v)| sum += v}}" - end - if outfile == '/dev/null' - # output nothing - elsif outfile == '-' || (infile == '-' && (outfile.nil? || outfile.empty?)) - (@out || $stdout).puts output + + if outfile == '-' || (infile == '-' && (outfile.to_s.empty? || outfile != '/dev/null')) + opts[:to_file] = (@out || $stdout) + elsif !outfile.nil? + opts[:to_file] = outfile else - if outfile.nil? || outfile.empty? - if @options[:destination_dir] - destination_dir = File.expand_path(@options[:destination_dir]) - else - destination_dir = @document.base_dir - end - outfile = File.join(destination_dir, "#{@document.attributes['docname']}#{@document.attributes['outfilesuffix']}") - else - outfile = @document.normalize_asset_path outfile - end + opts[:in_place] = true unless opts.has_key? :to_dir + end - # this assignment is primarily for testing or other post analysis - @document.attributes['outfile'] = outfile - @document.attributes['outdir'] = File.dirname(outfile) - File.open(outfile, 'w') {|file| file.write output } + @document = Asciidoctor.render(input, opts) + + # FIXME this should be :monitor, :profile or :timings rather than :verbose + if @options[:verbose] + puts "Time to read and parse source: #{'%05.5f' % monitor[:parse]}" + puts "Time to render document: #{'%05.5f' % monitor[:render]}" + puts "Total time to read, parse and render: #{'%05.5f' % monitor[:load_render]}" end rescue Exception => e raise e if @options[:trace] || SystemExit === e diff --git a/lib/asciidoctor/cli/options.rb b/lib/asciidoctor/cli/options.rb index eee4e413..58bbbb11 100644 --- a/lib/asciidoctor/cli/options.rb +++ b/lib/asciidoctor/cli/options.rb @@ -22,7 +22,7 @@ module Asciidoctor self[:eruby] = options[:eruby] || nil self[:compact] = options[:compact] || false self[:verbose] = options[:verbose] || false - self[:base_dir] = options[:base_dir] || nil + self[:base_dir] = options[:base_dir] self[:destination_dir] = options[:destination_dir] || nil self[:trace] = false end @@ -125,8 +125,8 @@ Example: asciidoctor -b html5 source.asciidoc if self[:input_file].nil? || self[:input_file].empty? $stderr.puts opts_parser return 1 - elsif self[:input_file] != '-' && !File.exist?(self[:input_file]) - $stderr.puts "asciidoctor: FAILED: input file #{self[:input_file]} missing" + elsif self[:input_file] != '-' && !File.readable?(self[:input_file]) + $stderr.puts "asciidoctor: FAILED: input file #{self[:input_file]} missing or cannot be read" return 1 end rescue OptionParser::MissingArgument diff --git a/lib/asciidoctor/document.rb b/lib/asciidoctor/document.rb index bbe6a02b..1cbbc717 100644 --- a/lib/asciidoctor/document.rb +++ b/lib/asciidoctor/document.rb @@ -127,13 +127,12 @@ class Document < AbstractBlock @safe = @options.fetch(:safe, SafeMode::SECURE).to_i @options[:header_footer] = @options.fetch(:header_footer, false) - @attributes['asciidoctor'] = '' - @attributes['asciidoctor-version'] = VERSION @attributes['encoding'] = 'UTF-8' @attributes['sectids'] = '' @attributes['notitle'] = '' unless @options[:header_footer] - @attributes['embedded'] = '' unless @options[:header_footer] @attributes['toc-placement'] = 'auto' + @attributes['stylesheet'] = '' + @attributes['linkcss'] = '' # language strings # TODO load these based on language settings @@ -150,8 +149,16 @@ class Document < AbstractBlock @attributes['toc-title'] = 'Table of Contents' # attribute overrides are attributes that can only be set from the commandline + # a direct assignment effectively makes the attribute a constant + # assigning a nil value will result in the attribute being unset @attribute_overrides = options[:attributes] || {} + @attribute_overrides['asciidoctor'] = '' + @attribute_overrides['asciidoctor-version'] = VERSION + + # sync the embedded attribute w/ the value of options...do not allow override + @attribute_overrides['embedded'] = @options[:header_footer] ? nil : '' + # the only way to set the include-depth attribute is via the document options # 10 is the AsciiDoc default, though currently Asciidoctor only supports 1 level @attribute_overrides['include-depth'] ||= 10 @@ -163,8 +170,8 @@ class Document < AbstractBlock if @attribute_overrides['docdir'] @base_dir = @attribute_overrides['docdir'] = File.expand_path(@attribute_overrides['docdir']) else - # perhaps issue a warning here? - @base_dir = @attribute_overrides['docdir'] = Dir.pwd + #puts 'asciidoctor: WARNING: setting base_dir is recommended when working with string documents' unless nested? + @base_dir = @attribute_overrides['docdir'] = File.expand_path(Dir.pwd) end else @base_dir = @attribute_overrides['docdir'] = File.expand_path(options[:base_dir]) @@ -180,7 +187,9 @@ class Document < AbstractBlock end if @safe >= SafeMode::SERVER - # restrict document from setting source-highlighter and backend + # restrict document from setting linkcss, copycss, source-highlighter and backend + @attribute_overrides['copycss'] ||= nil + @attribute_overrides['linkcss'] ||= '' @attribute_overrides['source-highlighter'] ||= nil @attribute_overrides['backend'] ||= DEFAULT_BACKEND # restrict document from seeing the docdir and trim docfile to relative path @@ -234,7 +243,9 @@ class Document < AbstractBlock @attributes['docdate'] ||= @attributes['localdate'] @attributes['doctime'] ||= @attributes['localtime'] @attributes['docdatetime'] ||= @attributes['localdatetime'] - + + # fallback directories + @attributes['stylesdir'] ||= '.' @attributes['iconsdir'] ||= File.join(@attributes.fetch('imagesdir', './images'), 'icons') # Now parse the lines in the reader into blocks @@ -639,20 +650,15 @@ class Document < AbstractBlock docinfo2 = @attributes.has_key?('docinfo2') docinfo_filename = "docinfo#{ext}" if docinfo1 || docinfo2 - docinfo_path = normalize_asset_path(docinfo_filename, 'shared docinfo file') - if File.exist?(docinfo_path) - content = File.read(docinfo_path) - end + docinfo_path = normalize_system_path(docinfo_filename) + content = read_asset(docinfo_path) end if (docinfo || docinfo2) && @attributes.has_key?('docname') - docinfo_path = normalize_asset_path("#{@attributes['docname']}-#{docinfo_filename}", 'document-specific docinfo file') - if File.exist?(docinfo_path) - if content.nil? - content = File.read(docinfo_path) - else - content = [content, File.read(docinfo_path)] * "\n" - end + docinfo_path = normalize_system_path("#{@attributes['docname']}-#{docinfo_filename}") + content2 = read_asset(docinfo_path) + unless content2.nil? + content = content.nil? ? content2 : "#{content}\n#{content2}" end end diff --git a/lib/asciidoctor/errors.rb b/lib/asciidoctor/errors.rb deleted file mode 100644 index acb577b0..00000000 --- a/lib/asciidoctor/errors.rb +++ /dev/null @@ -1,5 +0,0 @@ -# Base project exception -module Asciidoctor -class ProjectError < StandardError; end -end - diff --git a/lib/asciidoctor/path_resolver.rb b/lib/asciidoctor/path_resolver.rb new file mode 100644 index 00000000..ed4f6abe --- /dev/null +++ b/lib/asciidoctor/path_resolver.rb @@ -0,0 +1,360 @@ +module Asciidoctor +# Public: Handles all operations for resolving, cleaning and joining paths. +# This class includes operations for handling both web paths (request URIs) and +# system paths. +# +# The main emphasis of the class is on creating clean and secure paths. Clean +# paths are void of duplicate parent and current directory references in the +# path name. Secure paths are paths which are restricted from accessing +# directories outside of a jail root, if specified. +# +# Since joining two paths can result in an insecure path, this class also +# handles the task of joining a parent (start) and child (target) path. +# +# This class makes no use of path utilities from the Ruby libraries. Instead, +# it handles all aspects of path manipulation. The main benefit of +# internalizing these operations is that the class is able to handle both posix +# and windows paths independent of the operating system on which it runs. This +# makes the class both deterministic and easier to test. +# +# Examples +# +# resolver = PathResolver.new +# +# # Web Paths +# +# resolver.web_path('images') +# => 'images' +# +# resolver.web_path('./images') +# => './images' +# +# resolver.web_path('/images') +# => '/images' +# +# resolver.web_path('./images/../assets/images') +# => './assets/images' +# +# resolver.web_path('/../images') +# => '/images' +# +# resolver.web_path('images', 'assets') +# => 'assets/images' +# +# resolver.web_path('tiger.png', '../assets/images') +# => '../assets/images/tiger.png' +# +# # System Paths +# +# resolver.working_dir +# => '/path/to/docs' +# +# resolver.system_path('images') +# => '/path/to/docs/images' +# +# resolver.system_path('../images') +# => '/path/to/images' +# +# resolver.system_path('/etc/images') +# => '/etc/images' +# +# resolver.system_path('images', '/etc') +# => '/etc/images' +# +# resolver.system_path('', '/etc/images') +# => '/etc/images' +# +# resolver.system_path(nil, nil, '/path/to/docs') +# => '/path/to/docs' +# +# resolver.system_path('..', nil, '/path/to/docs') +# => '/path/to/docs' +# +# resolver.system_path('../../../css', nil, '/path/to/docs') +# => '/path/to/docs/css' +# +# resolver.system_path('../../../css', '../../..', '/path/to/docs') +# => '/path/to/docs/css' +# +# begin +# resolver.system_path('../../../css', '../../..', '/path/to/docs', :recover => false) +# rescue SecurityError => e +# puts e.message +# end +# => 'path ../../../../../../css refers to location outside jail: /path/to/docs (disallowed in safe mode)' +# +# resolver.system_path('/path/to/docs/images', nil, '/path/to/docs') +# => '/path/to/docs/images' +# +# begin +# resolver.system_path('images', '/etc', '/path/to/docs') +# rescue SecurityError => e +# puts e.message +# end +# => Start path /etc is outside of jail: /path/to/docs' +# +class PathResolver + DOT = '.' + DOT_DOT = '..' + SLASH = '/' + BACKSLASH = '\\' + PARTITION_RE = /\/+/ + WIN_ROOT_RE = /^[[:alpha:]]:(?:\\|\/)/ + + attr_accessor :file_separator + attr_accessor :working_dir + + # Public: Construct a new instance of PathResolver, optionally specifying the + # path separator (to override the system default) and the working directory + # (to override the present working directory). The working directory will be + # expanded to an absolute path inside the constructor. + # + # file_separator - the String file separator to use for path operations + # (optional, default: File::FILE_SEPARATOR) + # working_dir - the String working directory (optional, default: Dir.pwd) + # + def initialize(file_separator = nil, working_dir = nil) + @file_separator = file_separator.nil? ? File::SEPARATOR : file_separator + if working_dir.nil? + @working_dir = File.expand_path(Dir.pwd) + else + @working_dir = is_root?(working_dir) ? working_dir : File.expand_path(working_dir) + end + end + + # Public: Check if the specified path is an absolute root path + # This operation correctly handles both posix and windows paths. + # + # path - the String path to check + # + # returns a Boolean indicating whether the path is an absolute root path + def is_root?(path) + if @file_separator == BACKSLASH && path.match(WIN_ROOT_RE) + true + elsif path.start_with? SLASH + true + else + false + end + end + + # Public: Determine if the path is an absolute (root) web path + # + # path - the String path to check + # + # returns a Boolean indicating whether the path is an absolute (root) web path + def is_web_root?(path) + path.start_with? SLASH + end + + # Public: Normalize path by converting any backslashes to forward slashes + # + # path - the String path to normalize + # + # returns a String path with any backslashes replaced with forward slashes + def posixfy(path) + return '' if path.to_s.empty? + path.include?(BACKSLASH) ? path.tr(BACKSLASH, SLASH) : path + end + + # Public: Expand the path by resolving any parent references (..) + # and cleaning self references (.). + # + # The result will be relative if the path is relative and + # absolute if the path is absolute. The file separator used + # in the expanded path is the one specified when the class + # was constructed. + # + # path - the String path to expand + # + # returns a String path with any parent or self references resolved. + def expand_path(path) + path_segments, path_root, _ = partition_path(path) + join_path(path_segments, path_root) + end + + # Public: Partition the path into path segments and remove any empty segments + # or segments that are self references (.). The path is split on either posix + # or windows file separators. + # + # path - the String path to partition + # web_path - a Boolean indicating whether the path should be handled + # as a web path (optional, default: false) + # + # returns a 3-item Array containing the Array of String path segments, the + # path root, if the path is absolute, and the posix version of the path. + def partition_path(path, web_path = false) + posix_path = posixfy path + is_root = web_path ? is_web_root?(posix_path) : is_root?(posix_path) + path_segments = posix_path.split(PARTITION_RE) + # capture relative root + root = path_segments.first == DOT ? DOT : nil + path_segments.delete(DOT) + # capture absolute root, preserving relative root if set + root = is_root ? path_segments.shift : root + + [path_segments, root, posix_path] + end + + # Public: Join the segments using the file separator specified in the + # constructor. Use the root, if specified, to construct an absolute path. + # Otherwise join the segments as a relative path. + # + # segments - a String Array of path segments + # root - a String path root (optional, default: nil) + # + # returns a String path formed by joining the segments and prepending + # the root, if specified + def join_path(segments, root = nil) + if root + "#{root}#{@file_separator}#{segments * @file_separator}" + else + segments * @file_separator + end + end + + # Public: Resolve a system path from the target and start paths. If a jail + # path is specified, enforce that the resolved directory is contained within + # the jail path. If a jail path is not provided, the resolved path may be + # any location on the system. If the resolved path is absolute, use it as is. + # If the resolved path is relative, resolve it relative to the working_dir + # specified in the constructor. + # + # target - the String target path + # start - the String start (i.e., parent) path + # jail - the String jail path to confine the resolved path + # opts - an optional Hash of options to control processing (default: {}): + # * :recover is used to control whether the processor should auto-recover + # when an illegal path is encountered + # * :target_name is used in messages to refer to the path being resolved + # + # returns a String path that joins the target path with the start path with + # any parent references resolved and self references removed and enforces + # that the resolved path be contained within the jail, if provided + def system_path(target, start, jail = nil, opts = {}) + recover = opts.fetch(:recover, true) + unless jail.nil? + unless is_root? jail + raise SecurityError, "Jail is not an absolute path: #{jail}" + end + jail = posixfy jail + end + + if target.to_s.empty? + target_segments = [] + else + target_segments, target_root, _ = partition_path(target) + end + + if target_segments.empty? + if start.to_s.empty? + return jail.nil? ? @working_dir : jail + elsif is_root? start + if jail.nil? + return expand_path start + end + else + return system_path(start, jail, jail) + end + end + + if target_root && target_root != DOT + resolved_target = join_path target_segments, target_root + # if target is absolute and a sub-directory of jail, or + # a jail is not in place, let it slide + if jail.nil? || resolved_target.start_with?(jail) + return resolved_target + end + end + + if start.to_s.empty? + start = jail.nil? ? @working_dir : jail + elsif is_root? start + start = posixfy start + else + start = system_path(start, jail, jail) + end + + # both jail and start have been posixfied at this point + if jail == start + jail_segments, jail_root, _ = partition_path(jail) + start_segments = jail_segments.dup + elsif !jail.nil? + if !start.start_with?(jail) + raise SecurityError, "#{opts[:target_name] || 'Start path'} #{start} is outside of jail: #{jail} (disallowed in safe mode)" + end + + start_segments, start_root, _ = partition_path(start) + jail_segments, jail_root, _ = partition_path(jail) + + # Already checked for this condition + #if start_root != jail_root + # raise SecurityError, "Jail root #{jail_root} does not match root of #{opts[:target_name] || 'start path'}: #{start_root}" + #end + else + start_segments, start_root, _ = partition_path(start) + jail_root = start_root + end + + resolved_segments = start_segments.dup + warned = false + target_segments.each do |segment| + if segment == DOT_DOT + if !jail.nil? + if resolved_segments.length > jail_segments.length + resolved_segments.pop + elsif !recover + raise SecurityError, "#{opts[:target_name] || 'path'} #{target} refers to location outside jail: #{jail} (disallowed in safe mode)" + elsif !warned + puts "asciidoctor: WARNING: #{opts[:target_name] || 'path'} has illegal reference to ancestor of jail, auto-recovering" + warned = true + end + else + resolved_segments.pop + end + else + resolved_segments.push segment + end + end + + join_path resolved_segments, jail_root + end + + # Public: Resolve a web path from the target and start paths. + # The main function of this operation is to resolve any parent + # references and remove any self references. + # + # target - the String target path + # start - the String start (i.e., parent) path + # + # returns a String path that joins the target path with the + # start path with any parent references resolved and self + # references removed + def web_path(target, start = nil) + target = posixfy(target) + start = posixfy(start) + + unless is_web_root?(target) || start.empty? + target = "#{start}#{SLASH}#{target}" + end + + target_segments, target_root, _ = partition_path(target, true) + resolved_segments = target_segments.inject([]) do |accum, segment| + if segment == DOT_DOT + if accum.empty? + accum.push segment unless target_root && target_root != DOT + elsif accum[-1] == DOT_DOT + accum.push segment + else + accum.pop + end + else + accum.push segment + end + accum + end + + join_path resolved_segments, target_root + end +end +end diff --git a/lib/asciidoctor/reader.rb b/lib/asciidoctor/reader.rb index def2885b..e9e4f4e0 100644 --- a/lib/asciidoctor/reader.rb +++ b/lib/asciidoctor/reader.rb @@ -442,7 +442,7 @@ class Reader elsif @document.attributes.fetch('include-depth', 0).to_i > 0 advance # FIXME this borks line numbers - include_file = @document.normalize_asset_path(target, 'include file') + include_file = @document.normalize_system_path(target, nil, nil, :target_name => 'include file') if !File.file?(include_file) puts "asciidoctor: WARNING: line #{@lineno}: include file not found: #{include_file}" return true |
