Almirah 0.4.1 → 0.4.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b4a6b0c92714af081d54b235449ab825fa86842d61869f31cd331a44c702efdc
4
- data.tar.gz: cf2074e5c3f500689359912872166c1f13c08e59bde29408c90ee7b34fbe18fe
3
+ metadata.gz: 5221af43605bcaab2815ac5c940d53188394a92f8f8fadbbd2def016d1d52328
4
+ data.tar.gz: dd1d271774fe527d93512134b70cca3471048ff2a0859327c813eb75ebd35b54
5
5
  SHA512:
6
- metadata.gz: 21654dccede23f1828f02b4446f11f626d12ee5d7d3689b96636d6b1be0465de44fddbacd05606935326a27d4317bfaf0b5596813e907a38580ea6591bc430ca
7
- data.tar.gz: d3821259aeb7e5ff6306c3d9ccec25720030e659e351cd2c32a7791a8520ddea0039e6cab7f083b0244c82095e7c219c14de465bcda55b326eca184d06ce3bc7
6
+ metadata.gz: 6cf6a8d5892855c7b8752a2a3d447fec79dcf89a6a796b931709b2207ceeaddaede5cf7ac262ce13ab8b47aa081ead25fd225f353fff3afb84bc01dfaf2dfbdd
7
+ data.tar.gz: 9c0c6cffe8a41a19fc6778bcb0a3210f5a0ca3402b058d6b5f011b0434805fd11035be76a9ec6a742485839fe7523480d043923e582a2f0367c4fadb93922473
@@ -65,6 +65,7 @@ class DocFabric
65
65
  DocFabric.parse_document doc
66
66
  doc.extract_current_status
67
67
  doc.extract_start_date
68
+ doc.extract_target_date
68
69
  doc.extract_target_release_version
69
70
  doc
70
71
  end
@@ -19,7 +19,7 @@ class CodeBlock < DocItem
19
19
  end
20
20
  s += "<code>"
21
21
  @code_lines.each do |l|
22
- s += l + " </br>"
22
+ s += escape_text(l) + " </br>" # ADR-188/SRS-096: code content is inert text
23
23
  end
24
24
  s += "</code>\n"
25
25
  return s
@@ -57,8 +57,26 @@ class Heading < Paragraph
57
57
  end
58
58
  end
59
59
 
60
+ # As get_section_info but with the author-supplied text HTML-escaped for safe
61
+ # interpolation into rendered HTML (headings, TOC, traceability). ADR-188/SRS-096.
62
+ def get_section_info_html
63
+ if level.zero? # Doc Title
64
+ escape_text(@text)
65
+ else
66
+ "#{@section_number} #{escape_text(@text)}"
67
+ end
68
+ end
69
+
60
70
  def get_anchor_text
61
- "#{@section_number}-#{getTextWithoutSpaces}"
71
+ "#{@section_number}-#{anchor_slug}"
72
+ end
73
+
74
+ # The anchor is a generated identifier emitted into name/href attributes, so it
75
+ # must not carry the HTML-significant characters that author heading text may
76
+ # contain (ADR-188). Stripping them keeps the anchor inert and self-consistent
77
+ # across the heading, its self-link, and the table of contents.
78
+ def anchor_slug
79
+ getTextWithoutSpaces.gsub(/[<>"'&]/, '')
62
80
  end
63
81
 
64
82
  def get_markdown_anchor_text
@@ -72,10 +90,10 @@ class Heading < Paragraph
72
90
  @@html_table_render_in_progress = false
73
91
  end
74
92
  heading_level = level.to_s
75
- heading_text = get_section_info
93
+ heading_text = get_section_info_html
76
94
  if level.zero?
77
- heading_level = 1.to_s # Render Doc Title as a regular h1
78
- heading_text = @text # Doc Title does not have a section number
95
+ heading_level = 1.to_s # Render Doc Title as a regular h1
96
+ heading_text = escape_text(@text) # Doc Title does not have a section number
79
97
  end
80
98
  s += "<a name=\"#{@anchor_id}\"></a>\n"
81
99
  s += "<h#{heading_level}> #{heading_text} <a href=\"\##{@anchor_id}\" class=\"heading_anchor\">"
@@ -21,7 +21,14 @@ class Image < DocItem
21
21
  @@html_table_render_in_progress = false
22
22
  end
23
23
 
24
- s += "<p style=\"margin-top: 15px;\"><img src=\"#{@path}\" alt=\"#{@text}\" "
24
+ alt = escape_attr(@text)
25
+ src = safe_url(@path)
26
+ if src.nil? # disallowed scheme: render inert rather than emitting it (ADR-188, SRS-098)
27
+ s += "<p style=\"margin-top: 15px;\">[image: #{alt}]"
28
+ return s
29
+ end
30
+
31
+ s += "<p style=\"margin-top: 15px;\"><img src=\"#{escape_attr(src)}\" alt=\"#{alt}\" "
25
32
  s += "href=\"javascript:void(0)\" onclick=\"image_OnClick(this)\">"
26
33
  return s
27
34
  end
@@ -65,7 +65,7 @@ class MarkdownTable < DocItem
65
65
  s += "\t<thead>"
66
66
 
67
67
  @column_names.each do |h|
68
- s += " <th>#{h}</th>"
68
+ s += " <th>#{format_string(h.strip)}</th>"
69
69
  end
70
70
 
71
71
  s += " </thead>\n"
@@ -1,6 +1,7 @@
1
1
  require 'cgi'
2
2
  require 'uri'
3
3
  require_relative '../relative_url'
4
+ require_relative '../html_safe'
4
5
 
5
6
  class TextLineToken
6
7
  attr_accessor :value
@@ -217,6 +218,12 @@ class TextLineParser
217
218
  end
218
219
 
219
220
  class TextLineBuilderContext
221
+ # Literal (non-markup) text run. Subclasses encode it for the HTML context;
222
+ # the base context leaves it untouched for plain reconstruction/unit tests.
223
+ def literal_text(str)
224
+ str
225
+ end
226
+
220
227
  def italic(str)
221
228
  str
222
229
  end
@@ -363,7 +370,11 @@ class TextLineBuilder
363
370
  tii += 1
364
371
  end
365
372
  if is_found
366
- result += @builder_context.link(restore(sub_list_url_text), restore(sub_list_url_address))
373
+ # URL is reconstructed raw (not via restore) so scheme classification
374
+ # and file-path resolution see the original characters; link() applies
375
+ # attribute escaping and the scheme allow-list (ADR-188).
376
+ raw_url = (sub_list_url_address || []).map(&:value).join
377
+ result += @builder_context.link(restore(sub_list_url_text), raw_url)
367
378
  else
368
379
  result += '['
369
380
  ti = ti_starting_position + 1
@@ -373,7 +384,7 @@ class TextLineBuilder
373
384
  result += @builder_context.inline_code(token_list[ti].value)
374
385
  ti += 1
375
386
  when 'TextLineToken', 'ParentheseLeft', 'ParentheseRight', 'SquareBracketRight', 'DoubleSquareBracketRight'
376
- result += token_list[ti].value
387
+ result += @builder_context.literal_text(token_list[ti].value)
377
388
  ti += 1
378
389
  else
379
390
  ti += 1
@@ -384,6 +395,8 @@ class TextLineBuilder
384
395
  end
385
396
 
386
397
  class TextLine < TextLineBuilderContext
398
+ include HtmlSafe
399
+
387
400
  @@link_registry = nil # rubocop:disable Style/ClassVars
388
401
  @@broken_links = [] # rubocop:disable Style/ClassVars
389
402
 
@@ -423,6 +436,11 @@ class TextLine < TextLineBuilderContext
423
436
  tlb.restore(tlp.tokenize(str))
424
437
  end
425
438
 
439
+ # Literal text run, HTML-escaped for element content (ADR-188, SRS-096).
440
+ def literal_text(str)
441
+ escape_text(str)
442
+ end
443
+
426
444
  def italic(str)
427
445
  "<i>#{str}</i>"
428
446
  end
@@ -445,12 +463,15 @@ class TextLine < TextLineBuilderContext
445
463
  case kind
446
464
  when :internal
447
465
  href = RelativeUrl.between(owner_document.output_rel_path, target.output_rel_path, fragment: fragment)
448
- "<a href=\"#{href}\" class=\"external\">#{link_text}</a>"
466
+ "<a href=\"#{escape_attr(href)}\" class=\"external\">#{link_text}</a>"
449
467
  when :broken
450
468
  TextLine.record_broken_link(owner_document, raw)
451
- "<a href=\"#{raw}\" class=\"broken_link\" title=\"Unresolved cross-document link\">#{link_text}</a>"
469
+ "<a href=\"#{escape_attr(raw)}\" class=\"broken_link\" title=\"Unresolved cross-document link\">#{link_text}</a>"
452
470
  else
453
- "<a target=\"_blank\" rel=\"noopener\" href=\"#{raw}\" class=\"external\">#{link_text}</a>"
471
+ url = safe_url(raw)
472
+ return link_text if url.nil? # disallowed scheme: render inert (ADR-188, SRS-098)
473
+
474
+ "<a target=\"_blank\" rel=\"noopener\" href=\"#{escape_attr(url)}\" class=\"external\">#{link_text}</a>"
454
475
  end
455
476
  end
456
477
 
@@ -7,7 +7,7 @@ require_relative '../doc_items/markdown_table'
7
7
 
8
8
  class Decision < PersistentDocument # rubocop:disable Style/Documentation,Metrics/ClassLength
9
9
  attr_accessor :path, :sequence_number, :record_type, :html_rel_path, :root_prefix, :current_status,
10
- :start_date, :target_release_version, :specifications_path, :wrong_links_hash
10
+ :start_date, :target_date, :target_release_version, :specifications_path, :wrong_links_hash
11
11
 
12
12
  def initialize(file_path)
13
13
  super
@@ -16,6 +16,7 @@ class Decision < PersistentDocument # rubocop:disable Style/Documentation,Metric
16
16
  assign_id_parts(stem)
17
17
  @current_status = nil
18
18
  @start_date = nil
19
+ @target_date = nil
19
20
  @target_release_version = nil
20
21
  @wrong_links_hash = {}
21
22
  end
@@ -49,6 +50,11 @@ class Decision < PersistentDocument # rubocop:disable Style/Documentation,Metric
49
50
  @start_date = dates.min
50
51
  end
51
52
 
53
+ def extract_target_date
54
+ dates = collect_dates('Status', 'Date') + collect_dates('Scope', 'Target Date')
55
+ @target_date = dates.max
56
+ end
57
+
52
58
  def extract_target_release_version
53
59
  @target_release_version = lookup_cell(
54
60
  section_name: 'Software Versions',
@@ -54,7 +54,8 @@ class DecisionsOverview < BaseDocument # rubocop:disable Style/Documentation,Met
54
54
  s += "\t\t<td class=\"item_text\" style='padding: 5px;'>#{title_html}</td>\n"
55
55
  start_date_html = doc.start_date ? doc.start_date.strftime('%d-%m-%Y') : ''
56
56
  s += "\t\t<td class=\"item_meta\">#{start_date_html}</td>\n"
57
- s += "\t\t<td class=\"item_meta\"></td>\n"
57
+ target_date_html = doc.target_date ? doc.target_date.strftime('%d-%m-%Y') : ''
58
+ s += "\t\t<td class=\"item_meta\">#{target_date_html}</td>\n"
58
59
  s += "\t\t<td class=\"item_meta\">#{doc.target_release_version}</td>\n"
59
60
  s += "\t\t<td class=\"item_meta\"></td>\n"
60
61
  s += "</tr>\n"
@@ -75,7 +75,7 @@ class Traceability < BaseDocument
75
75
  top_item.down_links.each do |bottom_item|
76
76
  id_color = "style='background-color: ##{bottom_item.parent_doc.color};'"
77
77
  bottom_f_text = bottom_item.format_string( bottom_item.text )
78
- document_section = bottom_item.parent_heading.get_section_info
78
+ document_section = bottom_item.parent_heading.get_section_info_html
79
79
  s += "\t<tr>\n"
80
80
  s += "\t\t<td class=\"item_id\"><a href=\"./../#{top_item.parent_doc.id}/#{top_item.parent_doc.id}.html##{top_item.id}\" class=\"external\">#{top_item.id}</a></td>\n"
81
81
  s += "\t\t<td class=\"item_text\" style='width: 34%;'>#{top_f_text}</td>\n"
@@ -105,7 +105,7 @@ class Traceability < BaseDocument
105
105
  if bottom_item.parent_doc.id == @bottom_doc.id
106
106
 
107
107
  bottom_f_text = bottom_item.format_string( bottom_item.text )
108
- document_section = bottom_item.parent_heading.get_section_info
108
+ document_section = bottom_item.parent_heading.get_section_info_html
109
109
 
110
110
  s += "\t<tr>\n"
111
111
  s += "\t\t<td class=\"item_id\" #{id_color}><a href=\"./../#{top_item.parent_doc.id}/#{top_item.parent_doc.id}.html##{top_item.id}\" class=\"external\">#{top_item.id}</a></td>\n"
@@ -11,7 +11,7 @@ class DocSection
11
11
  s = ''
12
12
  s += "\t<li onclick=\"nav_toggle_expand_list(this, event)\">" \
13
13
  '<span class="fa-li"><i class="fa fa-minus-square-o"> </i></span>'
14
- s += "<a href=\"##{@heading.anchor_id}\">#{@heading.get_section_info}</a>\n"
14
+ s += "<a href=\"##{@heading.anchor_id}\">#{@heading.get_section_info_html}</a>\n"
15
15
  unless @sections.empty?
16
16
  s += "\t\t<ul class=\"fa-ul\">\n"
17
17
  @sections.each do |sub_section|
@@ -64,7 +64,7 @@ class Document
64
64
 
65
65
  def section_tree_to_html
66
66
  s = ''
67
- s += "<a href=\"##{@root_section.heading.anchor_id}\">#{@root_section.heading.get_section_info}</a>\n"
67
+ s += "<a href=\"##{@root_section.heading.anchor_id}\">#{@root_section.heading.get_section_info_html}</a>\n"
68
68
  unless @root_section.sections.empty?
69
69
  s += "\t<ul class=\"fa-ul\" style=\"margin-top: 2px;\">\n"
70
70
  @root_section.sections.each do |sub_section|
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+
5
+ # Shared HTML output-encoding helpers (ADR-188, SRS-096/097/098).
6
+ #
7
+ # Author-written Markdown is untrusted text and must be encoded for its HTML
8
+ # context at the point of output. These helpers are the single mechanism every
9
+ # renderer routes through, so coverage cannot drift item-by-item the way it did
10
+ # when only inline code and wiki-link text were escaped.
11
+ module HtmlSafe
12
+ # URL schemes permitted in link/image targets. Anything else (notably
13
+ # javascript:, data:, vbscript:) is treated as unsafe and rendered inert.
14
+ ALLOWED_URL_SCHEMES = %w[http https mailto].freeze
15
+
16
+ # Escapes literal text rendered into element content (the five characters:
17
+ # & < > " '). Used for paragraph, heading, blockquote, table-cell and fenced
18
+ # code block text. SRS-096.
19
+ def escape_text(str)
20
+ CGI.escapeHTML(str.to_s)
21
+ end
22
+
23
+ # Escapes a value interpolated into a quoted HTML attribute so it cannot
24
+ # terminate the attribute or introduce new attributes/elements. SRS-097.
25
+ def escape_attr(str)
26
+ CGI.escapeHTML(str.to_s)
27
+ end
28
+
29
+ # Returns the URL when it is a relative/anchor reference or carries an allowed
30
+ # scheme; returns nil for any other scheme so the caller can render the
31
+ # link/image inert. SRS-098.
32
+ def safe_url(raw)
33
+ url = raw.to_s.strip
34
+ scheme = url[/\A([a-z][a-z0-9+.-]*):/i, 1]
35
+ return url if scheme.nil? # relative path or anchor reference
36
+
37
+ ALLOWED_URL_SCHEMES.include?(scheme.downcase) ? url : nil
38
+ end
39
+ end
@@ -17,14 +17,14 @@ class LinkRegistry
17
17
  # Each registered document is expected to already carry an output_rel_path
18
18
  # (its generated page, relative to the build root).
19
19
  def register(doc)
20
- key = doc.id.to_s.downcase
21
- if @by_id.key?(key) && !@by_id[key].equal?(doc)
22
- @collisions << key
23
- else
24
- @by_id[key] = doc
25
- end
20
+ register_id(doc.id.to_s.downcase, doc)
26
21
  return unless doc.respond_to?(:path) && doc.path
27
22
 
23
+ # Also index by the filename stem so a document resolves by filename, not
24
+ # only by id (SRS-090). For specs/protocols the stem equals the id; for
25
+ # decision records the id is the truncated <letters>-<digits> prefix, so the
26
+ # full stem (e.g. "adr-170-introduce-decision-records") is a distinct alias.
27
+ register_id(File.basename(doc.path, '.*').downcase, doc)
28
28
  @by_source[File.expand_path(doc.path)] = doc
29
29
  end
30
30
 
@@ -35,4 +35,19 @@ class LinkRegistry
35
35
  def find_by_source(source_path)
36
36
  @by_source[File.expand_path(source_path)]
37
37
  end
38
+
39
+ private
40
+
41
+ # Adds an id/stem key for a document, recording a collision only when the key
42
+ # already maps to a different document. Registering the same document under
43
+ # both its id and its filename stem is expected and not a collision.
44
+ def register_id(key, doc)
45
+ return if key.empty?
46
+
47
+ if @by_id.key?(key) && !@by_id[key].equal?(doc)
48
+ @collisions << key
49
+ else
50
+ @by_id[key] = doc
51
+ end
52
+ end
38
53
  end
@@ -1,3 +1,8 @@
1
+ html {
2
+ /* Leave room for the fixed #top_nav so in-page anchor links
3
+ are not hidden behind it when navigating to them. */
4
+ scroll-padding-top: 40px;
5
+ }
1
6
  body {
2
7
  font-family: Verdana, sans-serif;
3
8
  font-size: 12px;
@@ -224,7 +229,9 @@ img{
224
229
  background: #EEEEEE;
225
230
  border: 1px solid #ddd;
226
231
  position: fixed;
227
- height: 100%; /* 100% Full-height */
232
+ top: 0; /* Anchor top and bottom so the pane spans exactly the */
233
+ bottom: 0; /* viewport; padding/border stay inside the border box so */
234
+ /* overflow-y scroll can reach the last items. */
228
235
  visibility: hidden;
229
236
  z-index: 1;
230
237
  overflow-y: auto;
@@ -74,7 +74,10 @@ function openNav() {
74
74
 
75
75
  modal.style.display = "block";
76
76
  modalImg.src = clicked.src;
77
- captionText.innerHTML = clicked.alt;
77
+ // Author-supplied alt text: insert as plain text, never as HTML (ADR-188).
78
+ // Reading clicked.alt yields the decoded value, so innerHTML here would
79
+ // re-parse author markup as live DOM (DOM-based XSS).
80
+ captionText.textContent = clicked.alt;
78
81
  }
79
82
 
80
83
  function modal_close_OnClick(clicked){
@@ -1,6 +1,18 @@
1
1
  // Do search only on the Index Page
2
2
  import { create, search, insert } from 'https://unpkg.com/@orama/orama@3.1.18/dist/browser/index.js'
3
3
 
4
+ // Author-derived values are treated as untrusted (ADR-188). Every result field
5
+ // is inserted with safe DOM interfaces (createTextNode / property assignment) and
6
+ // never parsed as HTML, and any URL is admitted only if it is a relative reference
7
+ // or uses an allowed scheme; otherwise the link is rendered inert.
8
+ const ALLOWED_URL_SCHEMES = ['http', 'https', 'mailto'];
9
+ function safeUrl(url) {
10
+ const s = (url == null ? '' : String(url)).trim();
11
+ const m = s.match(/^([a-z][a-z0-9+.-]*):/i);
12
+ if (!m) return s; // relative path or anchor
13
+ return ALLOWED_URL_SCHEMES.includes(m[1].toLowerCase()) ? s : '#';
14
+ }
15
+
4
16
  // Create DB
5
17
  const db = await create({
6
18
  schema: {
@@ -87,19 +99,20 @@ async function search_onKeyUp(){
87
99
  let cell = document.createElement("td");
88
100
  let i = document.createElement("i");
89
101
  i.classList.add("fa","fa-file-text-o");
90
- i.style.backgroundColor = "#" + doc_color;
102
+ if (/^[0-9a-fA-F]{3,8}$/.test(doc_color)) {
103
+ i.style.backgroundColor = "#" + doc_color;
104
+ }
91
105
  cell.appendChild(i);
92
106
  let textnode = document.createTextNode("\xa0" + doc_title);
93
107
  cell.appendChild(textnode);
94
108
  row.appendChild(cell)
95
109
  cell = document.createElement("td");
96
110
 
97
- const a = document.createElement('a');
111
+ const a = document.createElement('a');
98
112
  const link = document.createTextNode(heading_text)
99
113
  a.appendChild(link);
100
114
  a.title = heading_text;
101
- a.href = heading_url;
102
- document.body.appendChild(a);
115
+ a.href = safeUrl(heading_url);
103
116
 
104
117
  cell.appendChild(a);
105
118
  row.appendChild(cell)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: Almirah
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Oleksandr Ivanov
@@ -90,6 +90,7 @@ files:
90
90
  - lib/almirah/doc_types/traceability.rb
91
91
  - lib/almirah/dom/doc_section.rb
92
92
  - lib/almirah/dom/document.rb
93
+ - lib/almirah/html_safe.rb
93
94
  - lib/almirah/link_registry.rb
94
95
  - lib/almirah/navigation_pane.rb
95
96
  - lib/almirah/project.rb