Make the web repo-browsing experience render code and Markdown instead of
plain text — the core "calm place to read code" promise.
Markdown (MarkdownRenderer service):
- GFM via Redcarpet: tables, task lists, strikethrough, autolinks, footnotes
- Rouge-highlighted fenced code; heading anchor links with unique slugs
- relative links/images resolved against the repo + current ref
- allow-list HTML sanitization (rails-html-sanitizer) + pruning of
script/style/iframe blocks: README HTML/JS can never run on the app origin
Blob view:
- per-line Rouge highlighting extracted to SyntaxHighlighter, cached by blob SHA
- large-file handling: skip highlight past 512KB, truncate display past 5k
lines, never load files >5MB (view-raw escape hatch); binary/image preview
- .md blobs render as Markdown by default, ?plain=1 shows source
- line selection: click → #L120, shift-click → #L120-L140, highlight + URL
sync + scroll-on-load; "Copy permalink" pinned to the commit SHA
- copy buttons on code + a Clone HTTPS/SSH control on the repo page
Raw endpoint: X-Content-Type-Options: nosniff so user content (HTML/SVG)
can't execute as active content; images keep their real content-type.
Specs (RSpec, matching the project convention): renderer/sanitizer,
highlighter, the raw endpoint, and the rendered read experience end-to-end.
brakeman.ignore documents the new arg-list Open3 calls as shell-safe.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Seto Elkahfi committedJun 30, 2026 at 19:18 UTC89ccf9fe16d4a300d7f72d2ddad1a28aee31d93b
index 234dfc0..893e801 100644--- a/app/controllers/blobs_controller.rb+++ b/app/controllers/blobs_controller.rb@@ -16,6 +16,18 @@ class BlobsController < ApplicationController # is loaded through an <img> tag, which browsers sandbox. PREVIEW_IMAGE_TYPES = RAW_IMAGE_TYPES.merge("svg" => "image/svg+xml").freeze+ MARKDOWN_EXTENSIONS = %w[md markdown mdown mkd].freeze++ # Above this many bytes a text file is shown as plain (un-highlighted) text:+ # tokenizing megabyte-scale files is the main source of render jank.+ MAX_HIGHLIGHT_BYTES = 512 * 1024+ # Above this many bytes we don't render Markdown — show source instead.+ MAX_MARKDOWN_BYTES = 512 * 1024+ # Above this we don't read the blob into memory at all; only "view raw" works.+ MAX_LOAD_BYTES = 5 * 1024 * 1024+ # Display cap so a huge file never renders tens of thousands of <tr>s.+ MAX_DISPLAY_LINES = 5_000+ def show # A file path like ".../app.js" or "...style.css" makes Rails negotiate a # non-HTML format from the trailing extension — which has no template (→@@ -29,52 +41,125 @@ class BlobsController < ApplicationController @branch = params[:branch] @file_path = params[:path] @path_parts = @file_path.to_s.split("/").reject(&:blank?)+ @filename = File.basename(@file_path)+ @extension = File.extname(@filename).delete_prefix(".").downcase+ @plain = params[:plain].present?- content = GitRepositoryService.file_content(@repository.disk_path, @branch, @file_path)- if content.nil?+ @blob_sha = GitRepositoryService.blob_sha(@repository.disk_path, @branch, @file_path)+ @file_size = GitRepositoryService.blob_size(@repository.disk_path, @branch, @file_path)+ if @blob_sha.nil? || @file_size.nil? render file: Rails.public_path.join("404.html"), status: :not_found, layout: false return end- @raw_content = content- @filename = File.basename(@file_path)- @extension = File.extname(@filename).delete_prefix(".") @commit_count = GitRepositoryService.commit_count(@repository.disk_path, @branch)- @is_binary = content.encoding != Encoding::UTF_8 || !content.valid_encoding? || binary_content?(content)- @file_size = content.bytesize+ # Pin "copy permalink" to the commit the ref points at right now, so the+ # link keeps resolving to this exact content after the branch moves on.+ @commit_sha = GitRepositoryService.commit_sha(@repository.disk_path, @branch)+ @raw_path = repository_blob_raw_path(@owner.username, @repository.name, @branch, @file_path)- @image_type = PREVIEW_IMAGE_TYPES[@extension.downcase]+ @image_type = PREVIEW_IMAGE_TYPES[@extension] if @image_type- # Embed the image inline as a data URI so it previews without a second- # request. <img>-loaded content is sandboxed, so this is safe for SVG too.- @image_data_uri = "data:#{@image_type};base64,#{[ content ].pack('m0')}"- elsif !@is_binary- @highlighted_lines = highlight_lines(content, @filename)- @line_count = content.lines.count+ prepare_image+ else+ prepare_text end render :show end def raw- @branch = params[:branch]- @file_path = params[:path]+ branch = params[:branch]+ file_path = params[:path]- content = GitRepositoryService.file_content(@repository.disk_path, @branch, @file_path)+ content = GitRepositoryService.file_content(@repository.disk_path, branch, file_path) if content.nil? head :not_found return end- ext = File.extname(@file_path).delete_prefix(".").downcase+ ext = File.extname(file_path).delete_prefix(".").downcase+ # nosniff stops the browser from re-interpreting user content (e.g. an HTML+ # or SVG file served as text/plain) as active content on the app origin.+ response.headers["X-Content-Type-Options"] = "nosniff" send_data content, type: RAW_IMAGE_TYPES[ext] || "text/plain; charset=utf-8", disposition: "inline",- filename: File.basename(@file_path)+ filename: File.basename(file_path) end private+ def prepare_image+ if @file_size > MAX_LOAD_BYTES+ @too_large = true+ return+ end+ content = GitRepositoryService.file_content(@repository.disk_path, @branch, @file_path)+ # Embed inline as a data URI so it previews without a second request.+ # <img>-loaded content is sandboxed, so this is safe for SVG too.+ @image_data_uri = "data:#{@image_type};base64,#{[ content ].pack('m0')}"+ end++ def prepare_text+ if @file_size > MAX_LOAD_BYTES+ @too_large = true+ return+ end++ content = GitRepositoryService.file_content(@repository.disk_path, @branch, @file_path)+ if content.nil?+ render file: Rails.public_path.join("404.html"), status: :not_found, layout: false+ return+ end++ @is_binary = content.encoding != Encoding::UTF_8 || !content.valid_encoding? || binary_content?(content)+ return if @is_binary++ if markdown? && !@plain && @file_size <= MAX_MARKDOWN_BYTES+ @markdown_html = render_markdown(content)+ return+ end++ @line_count = content.lines.count+ lines = highlighted_lines(content)+ if lines.length > MAX_DISPLAY_LINES+ @truncated = true+ lines = lines.first(MAX_DISPLAY_LINES)+ end+ @highlighted_lines = lines+ end++ def markdown?+ MARKDOWN_EXTENSIONS.include?(@extension)+ end++ # Highlighted line fragments, cached by blob SHA (content-addressed, so the+ # cache is shared across refs and never goes stale). Files past the highlight+ # budget are escaped as plain text to keep large views snappy.+ def highlighted_lines(content)+ if @file_size > MAX_HIGHLIGHT_BYTES+ @highlight_skipped = true+ return content.lines.map { |l| ERB::Util.html_escape(l.chomp) }.map(&:html_safe)+ end++ Rails.cache.fetch([ "blob-hl", @blob_sha ]) do+ SyntaxHighlighter.lines(content, filename: @filename)+ end.map(&:html_safe)+ end++ def render_markdown(content)+ context = MarkdownRenderer::Context.new(+ username: @owner.username,+ repository: @repository.name,+ ref: @branch,+ dir: @path_parts[0..-2].join("/")+ )+ Rails.cache.fetch([ "md", @blob_sha, context.ref, context.dir ]) do+ MarkdownRenderer.render(content, context: context)+ end.html_safe+ end+ def load_owner @owner = User.find_by!(username: params[:username]) rescue ActiveRecord::RecordNotFound@@ -96,30 +181,4 @@ class BlobsController < ApplicationController def binary_content?(content) content.bytes.first(8192).include?(0) end-- def highlight_lines(content, filename)- lexer = Rouge::Lexer.guess(filename: filename, source: content)- html_formatter = Rouge::Formatters::HTML.new-- # Tokenize the entire file at once (preserves stateful lexer context across- # lines — e.g. multiline strings, heredocs) then split the token stream on- # newline boundaries to get one HTML fragment per source line.- lines = [[]]- lexer.lex(content).each do |token, value|- value.split(/(\n)/, -1).each do |part|- if part == "\n"- lines << []- else- lines.last << [token, part] unless part.empty?- end- end- end-- # Drop a trailing empty entry when the file ends with a newline.- lines.pop if lines.last&.empty?-- lines.map { |line_tokens| html_formatter.format(line_tokens).html_safe }- rescue StandardError- content.lines.map { |l| ERB::Util.html_escape(l.chomp).html_safe }- end end
new file mode 100644index 0000000..edbf5dc--- /dev/null+++ b/app/javascript/controllers/line_selection_controller.js@@ -0,0 +1,105 @@+import { Controller } from "@hotwired/stimulus"++// GitHub-style line selection for the blob view.+//+// click a line number -> #L120+// shift-click another -> #L120-L140 (range, in either direction)+// load with a #L.. hash -> highlight + scroll into view+// "copy permalink" action -> SHA-pinned URL + the current selection+//+// Markup contract:+// <table data-controller="line-selection"+// data-line-selection-permalink-base-value="/u/r/blob/<sha>/path">+// <tr id="L1" data-line-selection-target="row"> … <a data-action="…">1</a> …+export default class extends Controller {+ static targets = ["row"]+ static values = { permalinkBase: String }++ connect() {+ this.start = null+ this.end = null+ this.#applyFromHash()+ this._onHashChange = () => this.#applyFromHash()+ window.addEventListener("hashchange", this._onHashChange)+ }++ disconnect() {+ window.removeEventListener("hashchange", this._onHashChange)+ }++ // Bound to each line-number anchor via data-action="line-selection#select".+ select(event) {+ event.preventDefault()+ const line = Number(event.params.line)+ if (!line) return++ if (event.shiftKey && this.start) {+ this.end = line+ } else {+ this.start = line+ this.end = line+ }+ this.#render()+ this.#updateHash()+ }++ copyPermalink(event) {+ event.preventDefault()+ const base = this.permalinkBaseValue+ if (!base) return+ const origin = window.location.origin+ const url = `${origin}${base}${this.#hash()}`+ navigator.clipboard?.writeText(url).catch(() => {})+ this.#flash(event.currentTarget)+ }++ #applyFromHash() {+ const m = window.location.hash.match(/^#L(\d+)(?:-L?(\d+))?$/)+ if (!m) return+ this.start = Number(m[1])+ this.end = m[2] ? Number(m[2]) : this.start+ this.#render()+ const first = this.#row(Math.min(this.start, this.end))+ if (first) first.scrollIntoView({ block: "center" })+ }++ #render() {+ const [lo, hi] = this.#ordered()+ this.rowTargets.forEach((row) => {+ const n = this.#lineOf(row)+ row.classList.toggle("line-selected", lo !== null && n >= lo && n <= hi)+ })+ }++ #updateHash() {+ history.replaceState(null, "", this.#hash())+ }++ #hash() {+ const [lo, hi] = this.#ordered()+ if (lo === null) return ""+ return lo === hi ? `#L${lo}` : `#L${lo}-L${hi}`+ }++ #ordered() {+ if (!this.start) return [null, null]+ return [Math.min(this.start, this.end), Math.max(this.start, this.end)]+ }++ #row(line) {+ return this.rowTargets.find((r) => this.#lineOf(r) === line)+ }++ #lineOf(row) {+ return Number(row.dataset.line ?? row.id.replace(/^L/, ""))+ }++ #flash(el) {+ if (!el) return+ const original = el.dataset.originalLabel ?? el.textContent+ el.dataset.originalLabel = original+ el.textContent = "Copied"+ clearTimeout(this._t)+ this._t = setTimeout(() => { el.textContent = original }, 1500)+ }+}
app/services/git_repository_service.rb
+21
index e673ad0..536b557 100644--- a/app/services/git_repository_service.rb+++ b/app/services/git_repository_service.rb@@ -87,6 +87,27 @@ class GitRepositoryService out end+ # Object id of the blob at <ref>:<file_path>. Stable for identical content, so+ # it's the natural cache key for rendered/highlighted output. nil if missing.+ def self.blob_sha(path, branch, file_path)+ out, _err, status = Open3.capture3("git", "--git-dir", path, "rev-parse", "--verify", "--quiet", "#{branch}:#{file_path}")+ status.success? ? out.strip.presence : nil+ end++ # Byte size of the blob without loading it into memory — lets the blob view+ # decide up front whether a file is too large to highlight or render.+ def self.blob_size(path, branch, file_path)+ out, _err, status = Open3.capture3("git", "--git-dir", path, "cat-file", "-s", "#{branch}:#{file_path}")+ status.success? ? out.strip.to_i : nil+ end++ # Full commit SHA a ref currently points at. Permalinks pin to this so they+ # survive the branch moving on.+ def self.commit_sha(path, ref)+ out, _err, status = Open3.capture3("git", "--git-dir", path, "rev-parse", "--verify", "--quiet", "#{ref}^{commit}")+ status.success? ? out.strip.presence : nil+ end+ def self.commits(path, branch, limit: 20, offset: 0) format = "%H%x00%h%x00%s%x00%an%x00%ae%x00%ad%x00%cn" out, _err, status = Open3.capture3(
app/services/markdown_renderer.rb
+206
new file mode 100644index 0000000..d511f71--- /dev/null+++ b/app/services/markdown_renderer.rb@@ -0,0 +1,206 @@+require "redcarpet"+require "rouge"+require "rails-html-sanitizer"+require "cgi"++# Renders GitHub-Flavored Markdown to sanitized HTML for README/.md display.+#+# Pipeline: Redcarpet (with a Rouge-highlighting, anchor-adding renderer) →+# task-list post-processing → allow-list sanitization. The sanitizer is the+# trust boundary: nothing the README author writes can introduce <script>,+# <iframe>, inline event handlers, or `javascript:` URLs, so rendered READMEs+# can never run as active content on the app origin.+#+# When a +context+ is supplied (owner/repo/ref/dir), relative links and images+# are resolved against the repository at the current ref — links point at the+# blob view, images at the raw endpoint.+class MarkdownRenderer+ Context = Struct.new(:username, :repository, :ref, :dir, keyword_init: true)++ # Tags we knowingly emit. Anything else is stripped to text.+ ALLOWED_TAGS = %w[+ h1 h2 h3 h4 h5 h6 p br hr blockquote+ ul ol li dl dt dd+ strong em b i del s strike code pre span tt kbd sub sup+ a img+ table thead tbody tfoot tr th td+ input+ details summary div+ ].freeze++ ALLOWED_ATTRIBUTES = %w[+ href src alt title class id name+ align colspan rowspan start+ type checked disabled value+ aria-hidden rel target loading+ ].freeze++ def self.render(text, context: nil)+ new(context: context).render(text)+ end++ def initialize(context: nil)+ @context = context+ end++ def render(text)+ return "" if text.blank?++ html = engine.render(text.to_s)+ html = prune_dangerous_blocks(html)+ html = convert_task_lists(html)+ sanitize(html)+ end++ private++ # Raw inline HTML in Markdown flows through Redcarpet untouched. The allow-list+ # sanitizer below removes disallowed *tags* but keeps their text children, so a+ # <script> would leave its code behind as visible (inert) text. Drop these+ # blocks wholesale first so nothing leaks through — even as text.+ PRUNE_BLOCKS = %r{<(script|style|noscript|template|svg|math|head|title|object|embed)\b[^>]*>.*?</\1>}mi++ def prune_dangerous_blocks(html)+ html.gsub(PRUNE_BLOCKS, "")+ end++ def engine+ @engine ||= Redcarpet::Markdown.new(+ Renderer.new(context: @context),+ autolink: true,+ tables: true,+ fenced_code_blocks: true,+ strikethrough: true,+ superscript: true,+ footnotes: true,+ highlight: true,+ space_after_headers: true,+ no_intra_emphasis: true+ )+ end++ # GitHub-style task lists. Redcarpet renders "- [ ] foo" as a plain list item+ # ("[ ] foo"); rewrite the leading marker into a disabled checkbox. Handles+ # both tight ("<li>[ ] x") and loose ("<li>\n<p>[ ] x") list rendering.+ def convert_task_lists(html)+ html.gsub(%r{(<li>\s*(?:<p>\s*)?)\[([ xX])\]\s}) do+ prefix = Regexp.last_match(1)+ checked = Regexp.last_match(2) != " "+ box = %(<input type="checkbox" disabled#{checked ? ' checked' : ''}> )+ %(#{prefix.sub('<li>', '<li class="task-list-item">')}#{box})+ end+ end++ def sanitize(html)+ sanitizer.sanitize(+ html,+ tags: ALLOWED_TAGS,+ attributes: ALLOWED_ATTRIBUTES+ )+ end++ def sanitizer+ @sanitizer ||=+ if defined?(Rails::HTML5::SafeListSanitizer)+ Rails::HTML5::SafeListSanitizer.new+ else+ Rails::HTML4::SafeListSanitizer.new+ end+ end++ # Redcarpet HTML renderer that adds server-side syntax highlighting to fenced+ # code, anchor links to headings, and repository-relative URL resolution.+ class Renderer < Redcarpet::Render::HTML+ def initialize(context: nil)+ @context = context+ @heading_slugs = Hash.new(0)+ # safe_links_only blocks dangerous schemes (javascript:, data: …) at the+ # render layer too; sanitization is still the authoritative gate.+ super(safe_links_only: true, link_attributes: { rel: "nofollow noopener noreferrer" })+ end++ def block_code(code, language)+ lexer =+ (Rouge::Lexer.find(language.to_s.split.first) if language.present?) ||+ Rouge::Lexer.guess(source: code) rescue Rouge::Lexers::PlainText+ lexer ||= Rouge::Lexers::PlainText++ formatter = Rouge::Formatters::HTML.new+ inner = formatter.format(lexer.lex(code))+ lang_class = language.present? ? %( data-language="#{CGI.escapeHTML(language)}") : ""+ %(<pre class="highlight code-block"#{lang_class}><code>#{inner}</code></pre>)+ end++ def header(text, level)+ slug = slugify(text)+ level = level.clamp(1, 6)+ <<~HTML.strip+ <h#{level} id="#{slug}" class="heading-anchored"><a class="heading-anchor" href="##{slug}" aria-hidden="true">#</a>#{text}</h#{level}>+ HTML+ end++ def link(url, title, content)+ resolved = resolve(url, raw: false)+ title_attr = title.present? ? %( title="#{CGI.escapeHTML(title)}") : ""+ %(<a href="#{CGI.escapeHTML(resolved)}"#{title_attr} rel="nofollow noopener noreferrer">#{content}</a>)+ end++ def image(url, title, alt)+ resolved = resolve(url, raw: true)+ alt_attr = %( alt="#{CGI.escapeHTML(alt.to_s)}")+ title_attr = title.present? ? %( title="#{CGI.escapeHTML(title)}") : ""+ %(<img src="#{CGI.escapeHTML(resolved)}"#{alt_attr}#{title_attr} loading="lazy">)+ end++ private++ # Map a heading's inline HTML to a GitHub-compatible anchor slug, keeping+ # successive duplicates unique (foo, foo-1, foo-2 …).+ def slugify(text)+ base = text.gsub(/<[^>]+>/, "") # drop inline tags+ .downcase+ .gsub(/[^\w\- ]/, "") # drop punctuation+ .strip+ .gsub(/\s+/, "-")+ base = "section" if base.empty?+ n = @heading_slugs[base]+ @heading_slugs[base] += 1+ n.zero? ? base : "#{base}-#{n}"+ end++ # Resolve a possibly-relative link/image against the repo at the current+ # ref. Absolute URLs, anchors, and root-relative paths are left untouched.+ def resolve(url, raw:)+ url = url.to_s+ return url if @context.nil? || url.empty?+ return url if url.start_with?("#", "/", "mailto:")+ return url if url =~ %r{\A[a-z][a-z0-9+.\-]*://}i # has a scheme++ path = clean_path(url)+ return url if path.nil?++ kind = raw ? "raw" : "blob"+ "/#{@context.username}/#{@context.repository}/#{kind}/#{@context.ref}/#{path}"+ end++ # Join the link against the current file's directory and collapse "."/".."+ # without escaping the repository root.+ def clean_path(url)+ anchor = url[/#.*\z/]+ url = url.sub(/#.*\z/, "")+ segments = []+ base = @context.dir.to_s.split("/").reject(&:empty?)+ (base + url.split("/")).each do |seg|+ next if seg.empty? || seg == "."+ if seg == ".."+ return nil if segments.empty?+ segments.pop+ else+ segments << seg+ end+ end+ return nil if segments.empty?+ segments.join("/") + anchor.to_s+ end+ end+end
app/services/syntax_highlighter.rb
+41
new file mode 100644index 0000000..d5e7969--- /dev/null+++ b/app/services/syntax_highlighter.rb@@ -0,0 +1,41 @@+require "rouge"+require "cgi"++# Server-side syntax highlighting for the blob view. Returns one HTML fragment+# per source line so each line can be numbered and individually anchored.+#+# The whole file is tokenized in a single pass (so stateful constructs such as+# heredocs and multi-line strings highlight correctly), then the token stream+# is split on newline boundaries. Unknown languages fall back to a plain-text+# lexer; any lexer error falls back to HTML-escaped plain lines.+class SyntaxHighlighter+ def self.lines(content, filename:)+ new(content, filename: filename).lines+ end++ def initialize(content, filename:)+ @content = content+ @filename = filename+ end++ def lines+ lexer = Rouge::Lexer.guess(filename: @filename, source: @content) || Rouge::Lexers::PlainText+ formatter = Rouge::Formatters::HTML.new++ rows = [ [] ]+ lexer.lex(@content).each do |token, value|+ value.split(/(\n)/, -1).each do |part|+ if part == "\n"+ rows << []+ elsif !part.empty?+ rows.last << [ token, part ]+ end+ end+ end+ rows.pop if rows.last&.empty?++ rows.map { |tokens| formatter.format(tokens) }+ rescue StandardError+ @content.lines.map { |l| CGI.escapeHTML(l.chomp) }+ end+end
new file mode 100644index 0000000..1472c83--- /dev/null+++ b/config/brakeman.ignore@@ -0,0 +1,35 @@+{+ "ignored_warnings": [+ {+ "warning_type": "Command Injection",+ "warning_code": 14,+ "fingerprint": "1dbb9af6b074159eced6b9d2536bec26667c240f09e3a5bb49285b45775cca4b",+ "check_name": "Execute",+ "message": "Possible command injection",+ "file": "app/services/git_repository_service.rb",+ "line": 93,+ "note": "False positive: Open3.capture3 is called with a separate argument list (no shell), so the interpolated ref/path is passed to git as a single literal argv entry and cannot inject shell commands."+ },+ {+ "warning_type": "Command Injection",+ "warning_code": 14,+ "fingerprint": "0a470c5a46cd5b8ac1b0487e651f761caeeb961ac9bc408a33d2ca89257085c3",+ "check_name": "Execute",+ "message": "Possible command injection",+ "file": "app/services/git_repository_service.rb",+ "line": 100,+ "note": "False positive: Open3.capture3 is called with a separate argument list (no shell), so the interpolated ref/path is passed to git as a single literal argv entry and cannot inject shell commands."+ },+ {+ "warning_type": "Command Injection",+ "warning_code": 14,+ "fingerprint": "586ef55597b40deec327d690a446903cdfb89a65dfeb7962ad0f6f603d16f9fd",+ "check_name": "Execute",+ "message": "Possible command injection",+ "file": "app/services/git_repository_service.rb",+ "line": 107,+ "note": "False positive: Open3.capture3 is called with a separate argument list (no shell), so the interpolated ref is passed to git as a single literal argv entry and cannot inject shell commands."+ }+ ],+ "brakeman_version": "8.0.5"+}
spec/requests/blob_view_spec.rb
+78
new file mode 100644index 0000000..8ba8522--- /dev/null+++ b/spec/requests/blob_view_spec.rb@@ -0,0 +1,78 @@+# frozen_string_literal: true++require "rails_helper"+require "tmpdir"+require "fileutils"++# End-to-end checks for the rendered read experience: a README renders (not+# raw) on the repo landing page, source files get syntax highlighting + line+# numbers, .md blobs render, the permalink pins to a commit SHA, and malicious+# HTML in a README is neutralised.+RSpec.describe "Blob view", type: :request do+ around do |example|+ Dir.mktmpdir("blob-view-spec") do |tmp|+ @repo_dir = File.join(tmp, "demo.git")+ build_repo(@repo_dir,+ "README.md" => "# Demo Project\n\nHello <script>alert('xss')</script> world\n\n- [x] done\n- [ ] todo\n",+ "main.rb" => "def greet\n puts 'hi'\nend\n")+ @sha = `git --git-dir #{@repo_dir} rev-parse main`.strip+ example.run+ end+ end++ let(:user) { User.create!(smbcloud_id: 123_321, email: "blob-spec@example.com", username: "blobspec") }+ let!(:repo) { user.repositories.create!(name: "demo", disk_path: @repo_dir, default_branch: "main") }++ it "renders the README as HTML on the repo landing page" do+ get "/blobspec/demo"+ expect(response).to have_http_status(:success)+ expect(response.body).to include('<div class="px-6 py-6 prose-readme"')+ expect(response.body).to match(%r{<h1[^>]*>.*Demo Project}m)+ end++ it "neutralises malicious HTML in the README" do+ get "/blobspec/demo"+ # The payload is gone entirely; the only <script> tags left are the page+ # layout's own (JSON-LD / importmap), never the README's.+ expect(response.body).not_to include("alert('xss')")+ rendered = response.body[/prose-readme.*?<\/div>/m]+ expect(rendered).not_to include("<script")+ end++ it "shows syntax highlighting and per-line anchors for a source file" do+ get "/blobspec/demo/blob/main/main.rb"+ expect(response).to have_http_status(:success)+ expect(response.body).to include('id="L1"').and include('id="L2"')+ expect(response.body).to include('class="highlight"')+ end++ it "pins the copy-permalink base to the commit SHA, not the branch" do+ get "/blobspec/demo/blob/main/main.rb"+ expect(response.body).to include(+ %(data-line-selection-permalink-base-value="/blobspec/demo/blob/#{@sha}/main.rb")+ )+ end++ it "renders an .md blob as Markdown by default and as source with ?plain" do+ get "/blobspec/demo/blob/main/README.md"+ expect(response.body).to match(%r{<h1[^>]*>.*Demo Project}m)++ get "/blobspec/demo/blob/main/README.md?plain=1"+ expect(response.body).to include('id="L1"')+ end++ def build_repo(bare_path, files)+ FileUtils.mkdir_p(bare_path)+ system("git", "init", "--bare", bare_path, exception: true)+ Dir.mktmpdir do |work|+ system("git", "-C", work, "init", "-b", "main", exception: true)+ system("git", "-C", work, "config", "user.email", "t@example.com", exception: true)+ system("git", "-C", work, "config", "user.name", "Test", exception: true)+ files.each { |name, content| File.write(File.join(work, name), content) }+ system("git", "-C", work, "add", ".", exception: true)+ system("git", "-C", work, "commit", "-m", "seed", exception: true)+ system("git", "-C", work, "remote", "add", "origin", bare_path, exception: true)+ system("git", "-C", work, "push", "origin", "main", exception: true)+ end+ end+end
spec/requests/raw_blob_spec.rb
+75
new file mode 100644index 0000000..c4e142e--- /dev/null+++ b/spec/requests/raw_blob_spec.rb@@ -0,0 +1,75 @@+# frozen_string_literal: true++require "rails_helper"+require "tmpdir"+require "fileutils"+require "base64"++# The /raw endpoint is what install scripts, badges, and CI hit, so it must+# return the exact bytes with a sensible content-type and the nosniff guard+# that stops the browser from running user content as active content on the+# app origin.+RSpec.describe "Raw blob", type: :request do+ around do |example|+ Dir.mktmpdir("raw-blob-spec") do |tmp|+ @repo_dir = File.join(tmp, "demo.git")+ build_repo(@repo_dir,+ "hello.txt" => "plain bytes\n",+ "page.html" => "<h1>hi</h1>\n",+ "logo.png" => png_bytes)+ example.run+ end+ end++ let(:user) { User.create!(smbcloud_id: 987_654, email: "raw-spec@example.com", username: "rawspec") }+ let!(:repo) { user.repositories.create!(name: "demo", disk_path: @repo_dir, default_branch: "main") }++ it "returns exact bytes with text/plain and nosniff for a text file" do+ get "/rawspec/demo/raw/main/hello.txt"+ expect(response).to have_http_status(:success)+ expect(response.body).to eq("plain bytes\n")+ expect(response.media_type).to match(%r{text/plain})+ expect(response.headers["X-Content-Type-Options"]).to eq("nosniff")+ end++ it "serves HTML files as text/plain so they cannot run as active content" do+ get "/rawspec/demo/raw/main/page.html"+ expect(response).to have_http_status(:success)+ expect(response.media_type).to match(%r{text/plain})+ expect(response.headers["X-Content-Type-Options"]).to eq("nosniff")+ end++ it "serves images with their real content-type" do+ get "/rawspec/demo/raw/main/logo.png"+ expect(response).to have_http_status(:success)+ expect(response.media_type).to eq("image/png")+ expect(response.headers["X-Content-Type-Options"]).to eq("nosniff")+ end++ it "returns 404 for a missing file" do+ get "/rawspec/demo/raw/main/does-not-exist.txt"+ expect(response).to have_http_status(:not_found)+ end++ def build_repo(bare_path, files)+ FileUtils.mkdir_p(bare_path)+ system("git", "init", "--bare", bare_path, exception: true)+ Dir.mktmpdir do |work|+ system("git", "-C", work, "init", "-b", "main", exception: true)+ system("git", "-C", work, "config", "user.email", "t@example.com", exception: true)+ system("git", "-C", work, "config", "user.name", "Test", exception: true)+ files.each { |name, content| File.binwrite(File.join(work, name), content) }+ system("git", "-C", work, "add", ".", exception: true)+ system("git", "-C", work, "commit", "-m", "seed", exception: true)+ system("git", "-C", work, "remote", "add", "origin", bare_path, exception: true)+ system("git", "-C", work, "push", "origin", "main", exception: true)+ end+ end++ # Smallest valid 1x1 PNG.+ def png_bytes+ Base64.decode64(+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="+ )+ end+end
spec/services/markdown_renderer_spec.rb
+114
new file mode 100644index 0000000..4ee97cd--- /dev/null+++ b/spec/services/markdown_renderer_spec.rb@@ -0,0 +1,114 @@+# frozen_string_literal: true++require "rails_helper"++RSpec.describe MarkdownRenderer do+ def render(md, context: nil)+ described_class.render(md, context: context)+ end++ describe "sanitization (the security boundary)" do+ it "strips <script> tags and their contents" do+ html = render("Hello\n\n<script>alert('xss')</script>")+ expect(html).not_to include("<script")+ expect(html).not_to include("alert('xss')")+ end++ it "strips <iframe> elements" do+ expect(render("<iframe src='https://evil.example'></iframe>")).not_to include("<iframe")+ end++ it "strips inline event handlers" do+ html = render("<img src='x' onerror='alert(1)'>")+ expect(html).not_to include("onerror")+ expect(html).not_to include("alert(1)")+ end++ it "strips javascript: scheme links" do+ expect(render("[click](javascript:alert(1))")).not_to include("javascript:")+ end++ it "keeps safe http(s) links" do+ expect(render("[siGit](https://sigit.si)")).to include('href="https://sigit.si"')+ end+ end++ describe "GitHub-Flavored Markdown" do+ it "renders tables" do+ html = render("| a | b |\n|---|---|\n| 1 | 2 |")+ expect(html).to include("<table").and include("<th").and include("<td")+ end++ it "renders task lists as disabled checkboxes" do+ html = render("- [ ] todo\n- [x] done")+ expect(html.scan(/<input[^>]*type="checkbox"/).length).to eq(2)+ expect(html).to include("checked")+ expect(html).to include("task-list-item")+ end++ it "gives headings anchor ids and links" do+ html = render("# Hello World")+ expect(html).to include('id="hello-world"').and include('href="#hello-world"')+ end++ it "makes duplicate heading ids unique" do+ html = render("# Title\n\n# Title")+ expect(html).to include('id="title"').and include('id="title-1"')+ end++ it "syntax-highlights fenced code" do+ html = render("```ruby\nputs 1\n```")+ expect(html).to include('class="highlight code-block"').and include("<span")+ end++ it "autolinks bare URLs" do+ expect(render("see https://sigit.si for more")).to include('href="https://sigit.si"')+ end+ end++ describe "relative link/image resolution" do+ let(:ctx) { MarkdownRenderer::Context.new(username: "sigit", repository: "demo", ref: "main", dir: dir) }+ let(:dir) { "" }++ it "resolves a relative link to the blob view" do+ expect(render("[docs](docs/guide.md)", context: ctx))+ .to include('href="/sigit/demo/blob/main/docs/guide.md"')+ end++ it "resolves a relative image to the raw endpoint" do+ expect(render("", context: ctx))+ .to include('src="/sigit/demo/raw/main/assets/logo.png"')+ end++ context "from a nested directory" do+ let(:dir) { "docs" }++ it "resolves against the current directory" do+ expect(render("[sibling](other.md)", context: ctx))+ .to include('href="/sigit/demo/blob/main/docs/other.md"')+ end+ end++ context "with parent traversal" do+ let(:dir) { "docs/api" }++ it "collapses .. against the current directory" do+ expect(render("[up](../intro.md)", context: ctx))+ .to include('href="/sigit/demo/blob/main/docs/intro.md"')+ end+ end++ it "leaves absolute URLs untouched" do+ expect(render("[x](https://example.com/a)", context: ctx)).to include('href="https://example.com/a"')+ end++ it "leaves in-page anchors untouched" do+ expect(render("[top](#intro)", context: ctx)).to include('href="#intro"')+ end+ end++ it "returns an empty string for blank input" do+ expect(render("")).to eq("")+ expect(render(nil)).to eq("")+ end+end
spec/services/syntax_highlighter_spec.rb
+37
new file mode 100644index 0000000..5428faa--- /dev/null+++ b/spec/services/syntax_highlighter_spec.rb@@ -0,0 +1,37 @@+# frozen_string_literal: true++require "rails_helper"++RSpec.describe SyntaxHighlighter do+ it "returns one fragment per source line" do+ expect(described_class.lines("def a\n 1\nend\n", filename: "a.rb").length).to eq(3)+ end++ it "does not emit a trailing blank line when the file ends with a newline" do+ expect(described_class.lines("one\ntwo\n", filename: "f.txt").length).to eq(2)+ end++ it "counts the final line when there is no trailing newline" do+ expect(described_class.lines("one\ntwo", filename: "f.txt").length).to eq(2)+ end++ it "highlights a known language" do+ expect(described_class.lines("puts 'hi'\n", filename: "x.rb").first).to include("<span")+ end++ it "falls back to plain text for an unknown extension" do+ lines = described_class.lines("just text\n", filename: "x.unknownext")+ expect(lines.length).to eq(1)+ expect(lines.first).to include("just text")+ end++ it "escapes HTML in the content" do+ line = described_class.lines("<script>\n", filename: "x.unknownext").first+ expect(line).not_to include("<script>")+ expect(line).to include("<script>")+ end++ it "handles empty content" do+ expect(described_class.lines("", filename: "x.txt")).to eq([])+ end+end