okf 1.12.0 → 1.13.0

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.
@@ -54,6 +54,10 @@ module OKF
54
54
  @bundle.hubs
55
55
  end
56
56
 
57
+ def directories
58
+ @bundle.directories
59
+ end
60
+
57
61
  def directory_index
58
62
  @bundle.directory_index
59
63
  end
@@ -104,6 +108,17 @@ module OKF
104
108
  Concept::File.read(root: @root, path: path)
105
109
  end
106
110
 
111
+ # The raw markdown bytes for one concept id — read once through the same
112
+ # containment guard as #concept, but without the parse #concept pays for,
113
+ # so a caller that wants the file verbatim (never a re-serialized copy)
114
+ # does one read, not a read plus a discarded frontmatter parse. nil when no
115
+ # concept has that id; raises Path::Error if the file has become a symlink
116
+ # escaping the root, and the reader's own SystemCallError if it has gone.
117
+ def concept_source(id)
118
+ path = @bundle.paths_by_id[id] or return nil
119
+ Concept::File.new(root: @root, path: path).read
120
+ end
121
+
107
122
  # Materialize the in-memory bundle to disk (Writer validates §9 before
108
123
  # publishing, so a malformed bundle is never written).
109
124
  def save(overwrite: false)
@@ -131,8 +146,12 @@ module OKF
131
146
  end
132
147
 
133
148
  def log_content(path)
134
- File.read(File.join(@root, path), encoding: "UTF-8")
135
- rescue SystemCallError
149
+ # Live, but through the same containment as every other read: a log.md
150
+ # that was a real file at boot and is a symlink out of the root now falls
151
+ # back to the boot snapshot rather than serving the target — the same
152
+ # answer a vanished file gets, since an escape is a file we must not read.
153
+ SafeRead.read!(@root, File.join(@root, path))
154
+ rescue SystemCallError, Path::Error
136
155
  @bundle.reserved_content(path)
137
156
  end
138
157
  end
@@ -12,10 +12,17 @@ module OKF
12
12
  # cannot open at all — is retained as an unparseable entry (carrying the
13
13
  # ParseError message or the errno, so §9.1 can report it) rather than dropped
14
14
  # or raised. That tolerance is the whole §9 best-effort promise: one bad file
15
- # never breaks the rest, and this is the read every verb shares. Every read
16
- # goes through Path.join_under! so a symlinked or crafted path cannot escape
17
- # the bundle root that guard still raises, because a path leaving the root
18
- # is not a bad file, it is a bundle lying about its shape.
15
+ # never breaks the rest, and this is the read every verb shares.
16
+ #
17
+ # Containment is enforced twice, because the two ways out of the root are
18
+ # different. A crafted *path* (`..`, an absolute string) is caught lexically
19
+ # by Path.join_under!. A *symlink* whose name sits inside the root but whose
20
+ # target does not cannot be seen lexically — File.expand_path does not
21
+ # resolve links — so each file is also realpath-resolved and its real
22
+ # location checked against the real root before a byte is read. An escaping
23
+ # file joins the unparseable bucket rather than raising: a planted symlink is
24
+ # one bad file, and letting it take down the whole bundle read would hand any
25
+ # writer of a served directory a denial of service. §9.1 then names it.
19
26
  class Reader
20
27
  def self.read(dir)
21
28
  new(dir).read
@@ -32,9 +39,22 @@ module OKF
32
39
  reserved = []
33
40
  unparseable = []
34
41
 
35
- markdown_paths.each do |path|
42
+ paths = markdown_paths
43
+ # Resolved once for the whole loop, but never at the cost of the
44
+ # best-effort promise: if the root itself has become unreadable since
45
+ # the glob, this stays nil and each file's own SafeRead call raises
46
+ # inside the per-file rescue below — one bad bundle degrades to
47
+ # unparseable entries, it does not crash the read every verb shares.
48
+ real_root = begin
49
+ File.realpath(@root) unless paths.empty?
50
+ rescue SystemCallError
51
+ nil
52
+ end
53
+
54
+ paths.each do |path|
36
55
  begin
37
- content = File.read(Path.join_under!(@root, path), encoding: "UTF-8")
56
+ absolute = Path.join_under!(@root, path)
57
+ content = SafeRead.read!(@root, absolute, real_root: real_root)
38
58
  if Concept.reserved?(path)
39
59
  reserved << Entry.new(path: path, content: content)
40
60
  else
@@ -43,18 +63,21 @@ module OKF
43
63
  end
44
64
  rescue Markdown::Frontmatter::ParseError => e
45
65
  unparseable << Entry.new(path: path, content: content, error: e.message)
46
- rescue SystemCallError => e
47
- # A file that cannot be opened is one unusable file, not a broken
48
- # bundle. Letting the errno out of here breaks "one bad file never
49
- # breaks the rest" for every verb at once the read is the one path
50
- # they all share and it breaks it in the worst way: a backtrace,
51
- # under an exit code that claims the bundle is non-conformant. So it
52
- # joins the same bucket a bad frontmatter block does, and §9.1 reports
53
- # it naming the file and the errno.
66
+ rescue Path::Error, SystemCallError => e
67
+ # A file we cannot safely read is one unusable file, not a broken
68
+ # bundle: an errno on open, or a path that leaves the root — lexically
69
+ # (`..`, an absolute string) or through a symlink whose target escapes
70
+ # it. Letting either out of here breaks "one bad file never breaks the
71
+ # rest" for every verb at once the read is the one path they all
72
+ # share — and in the worst way: a backtrace under an exit code that
73
+ # claims non-conformance, or a served bundle taken down by one planted
74
+ # symlink. So it joins the same bucket a bad frontmatter block does,
75
+ # and §9.1 reports it naming the file and the reason.
54
76
  #
55
77
  # Its content is "" rather than nil: unknown, but every analyzer reads
56
- # it as text, and empty is the honest shape of a file we never saw
57
- # no links to resolve, no encoding to be invalid, nothing claimed.
78
+ # it as text, and empty is the honest shape of a file we never read
79
+ # no links to resolve, nothing claimed, and for a symlink escape, none
80
+ # of the target's bytes.
58
81
  unparseable << Entry.new(path: path, content: "", error: e.message)
59
82
  end
60
83
  end
data/lib/okf/bundle.rb CHANGED
@@ -160,6 +160,18 @@ module OKF
160
160
  # `id` must not move a concept out of the directory it lives in. Pure: derived
161
161
  # from the concepts and the reserved index text, no disk. Shared by the
162
162
  # `okf index` view and the server's Index panel (/index).
163
+ # Every directory this bundle has — the same set #directory_index enumerates
164
+ # (concepts, an index.md or a log.md, plus every ancestor), without building
165
+ # the map. It is the answer to "does this bundle have a directory named X?",
166
+ # and the CLI needs exactly that to decide whether `--dir root` names a real
167
+ # directory or the bundle root. Reading it off #catalog instead is the same
168
+ # question asked of a smaller set, which is how the two views came to
169
+ # disagree about one bundle. Memoized: the model is immutable once read,
170
+ # and the resolvers above ask per invocation, not per bundle load.
171
+ def directories
172
+ @directories ||= directory_set(concepts.map { |concept| File.dirname(concept.path) }.uniq)
173
+ end
174
+
163
175
  def directory_index
164
176
  by_dir = concepts.group_by { |concept| File.dirname(concept.path) }
165
177
  dirs = directory_set(by_dir.keys)
@@ -200,11 +212,14 @@ module OKF
200
212
  id.include?("/") ? id.split("/").first : "(root)"
201
213
  end
202
214
 
203
- # Every directory to show: those holding concepts or an index.md, plus each of
204
- # their ancestors up to the root, so the subdir tree stays connected even when
205
- # an intermediate directory holds nothing directly. Sorted with "." first.
215
+ # Every directory to show: those holding concepts, an index.md or a log.md,
216
+ # plus each of their ancestors up to the root, so the subdir tree stays
217
+ # connected even when an intermediate directory holds nothing directly. A
218
+ # scoped log counts because `okf log` reads it — a directory whose only file
219
+ # is its history still exists, and leaving it out is how the `root` alias
220
+ # beat a real `root/` for the second file kind in a row. Sorted "." first.
206
221
  def directory_set(concept_dirs)
207
- seed = concept_dirs + index_files.map { |path| File.dirname(path) }
222
+ seed = concept_dirs + (index_files + log_files).map { |path| File.dirname(path) }
208
223
  dirs = {}
209
224
  seed.each do |dir|
210
225
  current = dir
@@ -33,7 +33,7 @@ module OKF
33
33
  folder = OKF::Bundle::Folder.load(dir)
34
34
  report_skipped(folder)
35
35
  entries = folder.catalog
36
- selected = filter_entries(entries, options)
36
+ selected = filter_entries(entries, options, dir_scope(folder, options))
37
37
  return print_catalog_json(dir, selected, options) if options[:json]
38
38
 
39
39
  print_catalog(dir, selected, entries.size)
@@ -190,22 +190,52 @@ module OKF
190
190
  parser.on("--tag TAG", "only concepts carrying this tag") { |v| options[:tag] = v } if keys.include?(:tag)
191
191
  end
192
192
 
193
- def filter_entries(entries, options)
193
+ # The argument is resolved once per view, not once per entry: the `root`
194
+ # alias below has to consult the bundle's own directories, and that is a
195
+ # question about the whole set rather than about the row in hand.
196
+ #
197
+ # +dirs+ is that set, and every in-tree caller passes it (see #dir_scope)
198
+ # rather than deriving it from +entries+ — the catalog knows only
199
+ # directories that hold *concepts*, so a `root/` carrying an index.md and
200
+ # nothing else was invisible here while `dirs` and `index` (which read
201
+ # Bundle#directories) saw it. Two answers to one question about one
202
+ # bundle is how the alias survived its own fix.
203
+ #
204
+ # The default is a compatibility promise, not a shortcut: this base is
205
+ # what every plugin verb inherits, and 1.12.0 shipped the two-argument
206
+ # shape. An out-of-tree caller that never learned the third argument gets
207
+ # exactly the resolution it was written against — the alias folds with no
208
+ # directory set consulted — no better and no worse.
209
+ def filter_entries(entries, options, dirs = nil)
210
+ area = options[:area] && fold_area(options[:area], dirs)
211
+ base = options[:dir] && fold_dir(options[:dir], dirs)
194
212
  entries.select do |entry|
195
213
  (options[:type].nil? || fold(entry[:type]) == fold(options[:type])) &&
196
- (options[:area].nil? || fold(entry[:top_dir]) == fold_area(options[:area])) &&
197
- (options[:dir].nil? || under_dir?(entry[:dir], options[:dir])) &&
214
+ (area.nil? || fold(entry[:top_dir]) == area) &&
215
+ (base.nil? || under_dir?(entry[:dir], base)) &&
198
216
  (options[:tag].nil? || entry[:tags].any? { |tag| fold(tag) == fold(options[:tag]) })
199
217
  end
200
218
  end
201
219
 
220
+ # The directory set only when a flag is going to consult it. Deriving it
221
+ # walks every path up to the root, and #filter_entries reads it solely to
222
+ # resolve the `root`/`--area` aliases — a plain listing, or a type/tag
223
+ # narrowing, never needs the walk.
224
+ def dir_scope(folder, options)
225
+ options[:dir] || options[:area] ? folder.directories : nil
226
+ end
227
+
202
228
  # The one rule --dir is built on: a dir names itself and everything beneath
203
229
  # it. `--dir foo` reaches foo/bar, `--dir foo/bar` narrows, and `--dir .`
204
230
  # needs no special case at all — nothing starts with "./", so the root
205
231
  # selects only what lives directly in it.
206
- def under_dir?(entry_dir, wanted)
232
+ #
233
+ # +path+ arrives already folded, through #fold_dir for a user's argument
234
+ # and #fold for a stored one. A stored dir is never an alias — that is the
235
+ # distinction the old signature could not make, and it is what had a row
236
+ # named `root` counting the bundle root's subtree instead of its own.
237
+ def under_dir?(entry_dir, path)
207
238
  entry = fold(entry_dir)
208
- path = fold_dir(wanted)
209
239
  entry == path || entry.start_with?("#{path}/")
210
240
  end
211
241
 
@@ -213,16 +243,34 @@ module OKF
213
243
  value.to_s.downcase
214
244
  end
215
245
 
216
- def fold_area(value)
246
+ def fold_area(value, known = nil)
217
247
  folded = trim_slash(fold(value))
218
- folded == "root" ? "(root)" : folded
248
+ folded == "root" && !names_dir?(known, "root") ? "(root)" : folded
219
249
  end
220
250
 
221
251
  # `.` is the stored spelling of the root everywhere; `root` is the one a
222
252
  # shell needs no quoting for, and the only reason the two exist.
223
- def fold_dir(value)
253
+ #
254
+ # A bundle that really has a `root/` directory owns the word, and +known+
255
+ # — the directories that bundle actually holds — is what decides. The
256
+ # alias is a convenience; being able to name a directory at all is not, so
257
+ # the convenience yields. Without this, `--dir root` answered for the
258
+ # bundle root in such a bundle: the wrong concepts, exit 0, nothing said.
259
+ def fold_dir(value, known = nil)
224
260
  folded = trim_slash(fold(value))
225
- folded.empty? || folded == "root" ? "." : folded
261
+ return "." if folded.empty?
262
+
263
+ folded == "root" && !names_dir?(known, "root") ? "." : folded
264
+ end
265
+
266
+ # Does the bundle hold a directory by this name? An ancestor counts: a
267
+ # bundle whose only concept sits in `root/deep` still has a `root`, and
268
+ # `--dir root` has to reach it by the prefix rule above.
269
+ def names_dir?(known, name)
270
+ Array(known).any? do |dir|
271
+ folded = fold(dir)
272
+ folded == name || folded.start_with?("#{name}/")
273
+ end
226
274
  end
227
275
 
228
276
  # The human views print a directory with the slash that says it is one —
@@ -307,7 +355,7 @@ module OKF
307
355
 
308
356
  stored = known.each_with_object({}) { |dir, out| out[fold(dir)] = dir }
309
357
  Array(options[:dirs]).each_with_object([]) do |path, out|
310
- base = fold_dir(path)
358
+ base = fold_dir(path, known)
311
359
  next unless stored.key?(base)
312
360
 
313
361
  current = dir_parent(base)
@@ -331,7 +379,7 @@ module OKF
331
379
  # they do not define one. The ancestor chain is unioned on top by the
332
380
  # caller, which is also what tells a row apart from context.
333
381
  def select_dirs(dirs, options)
334
- bases = Array(options[:dirs]).map { |path| fold_dir(path) }
382
+ bases = Array(options[:dirs]).map { |path| fold_dir(path, dirs) }
335
383
  depth = options[:depth]&.to_i
336
384
  return dirs if bases.empty? && depth.nil?
337
385
  # No --dir means the whole bundle is the starting point, which is *not*
@@ -373,10 +421,17 @@ module OKF
373
421
 
374
422
  # The ids the filters select, resolved through the catalog metadata — or nil
375
423
  # when no filter is active, meaning keep everything.
376
- def filter_ids(folder, options)
424
+ #
425
+ # +dirs+ overrides the folder's own directory set, which is what a
426
+ # multi-bundle run passes: the `root` alias is a fact about a bundle, so
427
+ # resolving it per folder inside a loop let one `--dir root` mean the
428
+ # `root/` subtree in one bundle and the bundle root in the next, merged
429
+ # into a single ranking with nothing saying so. One invocation, one
430
+ # meaning — see Search#multi_search.
431
+ def filter_ids(folder, options, dirs = nil)
377
432
  return nil if options[:type].nil? && options[:area].nil? && options[:dir].nil? && options[:tag].nil?
378
433
 
379
- filter_entries(folder.catalog, options).map { |entry| entry[:id] }
434
+ filter_entries(folder.catalog, options, dirs || dir_scope(folder, options)).map { |entry| entry[:id] }
380
435
  end
381
436
 
382
437
  # §9 best-effort: the graph is built from concepts that parse. Surface any that
data/lib/okf/cli/dirs.rb CHANGED
@@ -79,7 +79,7 @@ module OKF
79
79
  def subtree_counts(entries)
80
80
  entries.each_with_object({}) do |entry, out|
81
81
  out[entry[:dir]] = entries.reduce(0) do |sum, other|
82
- under_dir?(other[:dir], entry[:dir]) ? sum + other[:count] : sum
82
+ under_dir?(other[:dir], fold(entry[:dir])) ? sum + other[:count] : sum
83
83
  end
84
84
  end
85
85
  end
data/lib/okf/cli/files.rb CHANGED
@@ -33,7 +33,7 @@ module OKF
33
33
  folder = OKF::Bundle::Folder.load(dir)
34
34
  report_skipped(folder)
35
35
  entries = folder.catalog
36
- selected = filter_entries(entries, options)
36
+ selected = filter_entries(entries, options, dir_scope(folder, options))
37
37
  return print_files_json(dir, selected, options) if options[:json]
38
38
 
39
39
  print_files(dir, selected, entries.size)
data/lib/okf/cli/index.rb CHANGED
@@ -91,11 +91,11 @@ module OKF
91
91
  # deprecated flag that quietly widens is worse than one that is merely old.
92
92
  # Nothing passed keeps the whole map.
93
93
  def select_directories(entries, options)
94
- areas = Array(options[:areas]).map { |area| fold_dir(area) }
94
+ all_dirs = entries.map { |entry| entry[:dir] }
95
+ areas = Array(options[:areas]).map { |area| fold_dir(area, all_dirs) }
95
96
  scoped = !options[:dirs].nil? || !options[:depth].nil?
96
97
  return [ entries, [] ] if areas.empty? && !scoped
97
98
 
98
- all_dirs = entries.map { |entry| entry[:dir] }
99
99
  wanted = scoped ? select_dirs(all_dirs, options) : []
100
100
  chain = ancestor_dirs(options, all_dirs) - wanted
101
101
  selected = entries.select do |entry|
@@ -209,16 +209,30 @@ module OKF
209
209
  #
210
210
  # Filters stay per-bundle — they are per-folder questions — so they apply to
211
211
  # the merged rows by (slug, id) afterwards.
212
+ #
213
+ # The one thing that is *not* a per-folder question is what `--dir root`
214
+ # means. The alias yields to a directory that really carries the name, so
215
+ # resolving it inside this loop made one flag mean two things in one
216
+ # ranking: the `root/` subtree where a bundle has one, the bundle root
217
+ # where it does not, merged with nothing in the output saying so. The
218
+ # served set answers it once, and a bundle without the directory then
219
+ # matches nothing — which is what `--dir` already does everywhere for a
220
+ # directory a bundle lacks.
212
221
  def multi_search(pairs, terms, options)
213
- bundles = []
214
- keeps = {}
215
- total = 0
216
- pairs.each do |slug, dir|
222
+ folders = pairs.map do |slug, dir|
217
223
  folder = OKF::Bundle::Folder.load(dir)
218
224
  report_skipped(folder)
219
- total += folder.bundle.concepts.size
220
- bundles << [ slug, folder.bundle ]
221
- keep = filter_ids(folder, options)
225
+ [ slug, folder ]
226
+ end
227
+ total = folders.reduce(0) { |sum, (_, folder)| sum + folder.bundle.concepts.size }
228
+ bundles = folders.map { |slug, folder| [ slug, folder.bundle ] }
229
+ # The served set's directories, only when a flag will consult them —
230
+ # the alias is resolved once across the whole run (see filter_ids),
231
+ # and an unfiltered search never pays the walk.
232
+ dirs = options[:dir] || options[:area] ? folders.flat_map { |_, folder| folder.directories }.uniq : nil
233
+ keeps = {}
234
+ folders.each do |slug, folder|
235
+ keep = filter_ids(folder, options, dirs)
222
236
  keeps[slug] = keep unless keep.nil?
223
237
  end
224
238
  rows = OKF::Bundle::Search.across(bundles, terms, fields: options[:in], regexp: options[:regexp],
data/lib/okf/cli/tags.rb CHANGED
@@ -61,7 +61,7 @@ module OKF
61
61
  # makes a tag's spread — local to one group, or cutting across several —
62
62
  # readable without cross-referencing the groups by hand.
63
63
  def tag_groups(tag_index, folder, options)
64
- by_id = filter_entries(folder.catalog, options).map { |entry| [ entry[:id], entry ] }.to_h
64
+ by_id = filter_entries(folder.catalog, options, dir_scope(folder, options)).map { |entry| [ entry[:id], entry ] }.to_h
65
65
  groups = {}
66
66
  totals = Hash.new(0)
67
67
  tag_index.each do |tag, ids|
@@ -14,6 +14,13 @@ module OKF
14
14
  #
15
15
  # NOTE: this class is named File, which shadows Ruby's File inside the
16
16
  # OKF::Concept namespace — every filesystem call here uses ::File explicitly.
17
+ #
18
+ # absolute_path guards the *name* lexically (Path.join_under!), which is all a
19
+ # write needs — the file may not exist yet. A read has more to prove: the file
20
+ # is on disk now, so it may be a symlink whose name is inside the root but
21
+ # whose target is not, and File.expand_path does not resolve links. So #read
22
+ # goes through SafeRead, which realpath-resolves and refuses a target outside
23
+ # the root, closing the same escape Bundle::Reader closes on the bulk read.
17
24
  class File
18
25
  attr_reader :root, :path, :concept
19
26
 
@@ -53,11 +60,19 @@ module OKF
53
60
  end
54
61
 
55
62
  def reload
56
- content = ::File.read(absolute_path, encoding: "UTF-8")
57
- frontmatter, body = Markdown::Frontmatter.parse(content)
63
+ frontmatter, body = Markdown::Frontmatter.parse(read)
58
64
  @concept = Concept.new(path: @path, frontmatter: frontmatter, body: body)
59
65
  self
60
66
  end
67
+
68
+ # The file's own bytes, refused if the resolved target escapes the root by
69
+ # symlink. This is the guarded read a caller that wants the raw markdown
70
+ # (not a re-serialized `concept.to_markdown`) must use instead of reading
71
+ # #absolute_path itself — that path guards the *name* lexically, which a
72
+ # write needs but a read does not, since the file exists and may be a link.
73
+ def read
74
+ SafeRead.read!(@root, absolute_path)
75
+ end
61
76
  end
62
77
  end
63
78
  end
data/lib/okf/path.rb CHANGED
@@ -24,11 +24,25 @@ module OKF
24
24
  relative = normalize_relative!(path)
25
25
  expanded_root = File.expand_path(root.to_s)
26
26
  expanded_path = File.expand_path(File.join(expanded_root, relative))
27
- unless expanded_path == expanded_root || expanded_path.start_with?("#{expanded_root}#{File::SEPARATOR}")
28
- raise Error, "path escapes bundle root"
29
- end
27
+ raise Error, "path escapes bundle root" unless under?(expanded_root, expanded_path)
30
28
 
31
29
  expanded_path
32
30
  end
31
+
32
+ # Is +path+ the root itself or a descendant of it? Pure string containment
33
+ # (no disk access), so it works on both lexical paths (File.expand_path) and
34
+ # symlink-resolved ones (File.realpath) — the shell resolves, this decides.
35
+ # Both arguments must already be absolute and normalized the same way.
36
+ #
37
+ # The prefix guards against a sibling passing as a child ("/foo" is not under
38
+ # "/food"), and reuses the root itself as the prefix when the root already
39
+ # ends in the separator — i.e. the filesystem root "/", whose children would
40
+ # otherwise be tested against "//" and every one rejected.
41
+ def self.under?(root, path)
42
+ return true if path == root
43
+
44
+ prefix = root.end_with?(File::SEPARATOR) ? root : "#{root}#{File::SEPARATOR}"
45
+ path.start_with?(prefix)
46
+ end
33
47
  end
34
48
  end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "path"
4
+
5
+ module OKF
6
+ # Shell-side containment for reads. `Path.under?` is the pure decision; the
7
+ # `File.realpath` that feeds it is disk I/O, so it lives here, out of the pure
8
+ # core. Every byte a bundle serves is read through this one primitive — the
9
+ # Reader's bulk load, `Concept::File`, the live `log.md` re-read, the MCP
10
+ # shell's concept and index reads — so a symlink whose name sits inside the
11
+ # root but whose target does not is refused in exactly one place. A read that
12
+ # rolled its own check could quietly drift and reopen the escape; there is
13
+ # nothing to drift from here.
14
+ #
15
+ # Scope. This closes the escape a *symlink* opens — the one a bundle can carry
16
+ # through a git clone, a copy or a tarball, which is the portable, adversarial
17
+ # case (a shared bundle whose author points a link at your secrets). It does
18
+ # not close a *hardlink*: File.realpath cannot resolve one (a hardlink shares
19
+ # its target's inode and keeps its own in-root path), and the obvious guard —
20
+ # rejecting st_nlink > 1 — would break a bundle on a deduplicating filesystem
21
+ # (a Nix store, some CI caches) where ordinary files legitimately share links.
22
+ # A hardlink to an outside file requires local write access to the served
23
+ # directory on the target's own filesystem, and cannot survive being copied,
24
+ # so it is a narrower, non-portable threat left deliberately out of scope.
25
+ module SafeRead
26
+ module_function
27
+
28
+ # The file's real, symlink-resolved location, or Path::Error if it escapes
29
+ # +root+. Pass +real_root+ when resolving many paths under one root (the
30
+ # Reader's loop) so the root is resolved once, not per file.
31
+ def contained_path!(root, path, real_root: nil)
32
+ real = ::File.realpath(path)
33
+ real_root ||= ::File.realpath(root)
34
+ raise Path::Error, "symlink target escapes bundle root" unless Path.under?(real_root, real)
35
+
36
+ real
37
+ end
38
+
39
+ # +path+'s bytes, read from its *resolved* location — so a symlink swapped in
40
+ # anywhere but the final component is caught, since the resolved path has no
41
+ # links left to follow — and refused if it escapes. The microscopic window
42
+ # between resolving and opening the leaf is not closed here (that needs an
43
+ # open-by-descriptor the 2.4 stdlib does not lend itself to); reading the
44
+ # resolved path is strictly better than reading the caller's raw name, which
45
+ # re-followed every link on every read.
46
+ def read!(root, path, real_root: nil, encoding: "UTF-8")
47
+ ::File.read(contained_path!(root, path, real_root: real_root), encoding: encoding)
48
+ end
49
+ end
50
+ end
@@ -275,7 +275,8 @@ enumeration drift a grep can't (you can't grep for a listing entry that is *miss
275
275
 
276
276
  `--dir PATH` narrows to a directory **and everything below it**, and is
277
277
  **repeatable** — `--dir model --dir format` shows both; `root` (or `.`) names the
278
- bundle root. A `--dir` also brings the **chain from the root down to it**, so a branch is
278
+ bundle root, unless the bundle really has a `root/` directory, which owns the
279
+ word. A `--dir` also brings the **chain from the root down to it**, so a branch is
279
280
  never shown adrift of the authored context that says what it is — the root
280
281
  `index.md`'s prose first among it. Those rows print with a leading `↑` and carry
281
282
  `ancestor: true`; `--no-ancestors` drops them. Ascent and descent are separate
@@ -392,7 +393,9 @@ The four list views narrow with the same filters the browser panels offer —
392
393
  itself (`tags` can't filter by tag). Matching is case-insensitive; `--type` and
393
394
  `--tag` are exact, `--dir` takes the named directory **and everything below it**
394
395
  (`--dir platform` reaches `platform/services/api`). A concept at the bundle root
395
- lives in `.`, which `--dir` also accepts as plain `root` (no shell quoting). A
396
+ lives in `.`, which `--dir` also accepts as plain `root` (no shell quoting)
397
+ except in a bundle holding a real `root/` directory, where that directory takes
398
+ the name and `.` is the only spelling of the bundle root. A
396
399
  filter that matches nothing is an empty view, not an error: `okf tags <dir> --dir
397
400
  billing --json` answers "which tags does the billing cluster use?",
398
401
  `okf catalog <dir> --tag auth` answers "what carries the auth tag?".
data/lib/okf/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OKF
4
- VERSION = "1.12.0"
4
+ VERSION = "1.13.0"
5
5
  end
data/lib/okf.rb CHANGED
@@ -38,6 +38,7 @@ module OKF
38
38
 
39
39
  # ── kernel: cross-cutting primitives ──
40
40
  require "okf/path"
41
+ require "okf/safe_read"
41
42
 
42
43
  # ── Markdown: parse structure out of a markdown document (§4/§5/§8) ──
43
44
  require "okf/markdown/frontmatter"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: okf
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.12.0
4
+ version: 1.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rodrigo Serradura
@@ -78,7 +78,6 @@ extensions: []
78
78
  extra_rdoc_files: []
79
79
  files:
80
80
  - CHANGELOG.md
81
- - CODE_OF_CONDUCT.md
82
81
  - LICENSE.txt
83
82
  - NOTICE
84
83
  - README.md
@@ -124,6 +123,7 @@ files:
124
123
  - lib/okf/registry.rb
125
124
  - lib/okf/render/graph.rb
126
125
  - lib/okf/render/graph/template.html.erb
126
+ - lib/okf/safe_read.rb
127
127
  - lib/okf/server/app.rb
128
128
  - lib/okf/server/hub.rb
129
129
  - lib/okf/server/hub/not_found.rb
@@ -155,7 +155,7 @@ metadata:
155
155
  allowed_push_host: https://rubygems.org
156
156
  homepage_uri: https://github.com/serradura/okf-gem
157
157
  source_code_uri: https://github.com/serradura/okf-gem
158
- changelog_uri: https://github.com/serradura/okf-gem/blob/main/CHANGELOG.md
158
+ changelog_uri: https://github.com/serradura/okf-gem/blob/main/okf/CHANGELOG.md
159
159
  rubygems_mfa_required: 'true'
160
160
  rdoc_options: []
161
161
  require_paths:
data/CODE_OF_CONDUCT.md DELETED
@@ -1,10 +0,0 @@
1
- # Code of Conduct
2
-
3
- "okf" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
4
-
5
- * Participants will be tolerant of opposing views.
6
- * Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
7
- * When interpreting the words and actions of others, participants should always assume good intentions.
8
- * Behaviour which can be reasonably considered harassment will not be tolerated.
9
-
10
- If you have any concerns about behaviour within this project, please contact us at ["rodrigo.serradura@gmail.com"](mailto:"rodrigo.serradura@gmail.com").