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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +101 -0
- data/README.md +73 -464
- data/lib/okf/bundle/folder.rb +21 -2
- data/lib/okf/bundle/reader.rb +39 -16
- data/lib/okf/bundle.rb +19 -4
- data/lib/okf/cli/catalog.rb +1 -1
- data/lib/okf/cli/command.rb +68 -13
- data/lib/okf/cli/dirs.rb +1 -1
- data/lib/okf/cli/files.rb +1 -1
- data/lib/okf/cli/index.rb +2 -2
- data/lib/okf/cli/search.rb +21 -7
- data/lib/okf/cli/tags.rb +1 -1
- data/lib/okf/concept/file.rb +17 -2
- data/lib/okf/path.rb +17 -3
- data/lib/okf/safe_read.rb +50 -0
- data/lib/okf/skill/reference/cli.md +5 -2
- data/lib/okf/version.rb +1 -1
- data/lib/okf.rb +1 -0
- metadata +3 -3
- data/CODE_OF_CONDUCT.md +0 -10
data/lib/okf/bundle/folder.rb
CHANGED
|
@@ -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
|
-
|
|
135
|
-
|
|
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
|
data/lib/okf/bundle/reader.rb
CHANGED
|
@@ -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.
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
48
|
-
# bundle
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
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
|
|
57
|
-
# no links to resolve,
|
|
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
|
|
204
|
-
# their ancestors up to the root, so the subdir tree stays
|
|
205
|
-
# an intermediate directory holds nothing directly.
|
|
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
|
data/lib/okf/cli/catalog.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_catalog_json(dir, selected, options) if options[:json]
|
|
38
38
|
|
|
39
39
|
print_catalog(dir, selected, entries.size)
|
data/lib/okf/cli/command.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
(
|
|
197
|
-
(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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|
|
data/lib/okf/cli/search.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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|
|
data/lib/okf/concept/file.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
data/lib/okf.rb
CHANGED
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.
|
|
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").
|