Skip to content

Commit 83b82aa

Browse files
committed
Add cycle validation helpers
Introduce validate_acyclic!/acyclic? with tests and document the new API for checking untrusted trees.
1 parent fac77fb commit 83b82aa

File tree

3 files changed

+74
-0
lines changed

3 files changed

+74
-0
lines changed

API-CHANGES.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ smooth transition to the new APIs.
1515
now treat `nil` child slots as empty, preventing binary trees with missing
1616
children from reporting or yielding phantom siblings.
1717

18+
* Added [Tree::TreeNode#validate_acyclic!][validate_acyclic] and
19+
[Tree::TreeNode#acyclic?][acyclic] to detect cycles in untrusted trees.
20+
1821
## Release 2.2.0 Changes
1922

2023
* [Tree::TreeNode#add][add] now raises `ArgumentError` when attempting to add
@@ -166,6 +169,7 @@ smooth transition to the new APIs.
166169

167170
[access]: rdoc-ref:Tree::TreeNode#[]
168171
[add]: rdoc-ref:Tree::TreeNode#add
172+
[acyclic]: rdoc-ref:Tree::TreeNode#acyclic?
169173
[append]: rdoc-ref:Tree::TreeNode#<<
170174
[breadth_each]: rdoc-ref:Tree::TreeNode#breadth_each
171175
[btree_add]: rdoc-ref:Tree::BinaryTreeNode#add
@@ -192,3 +196,4 @@ smooth transition to the new APIs.
192196
[set_child_at]: rdoc-ref:Tree::BinaryTreeNode#set_child_at
193197
[siblings]: rdoc-ref:Tree::TreeNode#siblings
194198
[to_json]: rdoc-ref:Tree::Utils::JSONConverter#to_json
199+
[validate_acyclic]: rdoc-ref:Tree::TreeNode#validate_acyclic!

lib/tree.rb

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,51 @@ def parentage
199199
parentage_array
200200
end
201201

202+
# @!attribute [r] acyclic?
203+
# Returns +true+ if this (sub)tree has no cycles.
204+
#
205+
# @return [Boolean] +true+ if the (sub)tree is acyclic.
206+
def acyclic?
207+
validate_acyclic!
208+
true
209+
rescue ArgumentError
210+
false
211+
end
212+
213+
# Validates that the (sub)tree rooted at this node has no cycles.
214+
#
215+
# @raise [ArgumentError] Raised when a cycle is detected.
216+
# @return [Tree::TreeNode] Returns +self+ when no cycle is found.
217+
def validate_acyclic!
218+
visited = {}
219+
visiting = {}
220+
stack = [[self, :enter]]
221+
222+
until stack.empty?
223+
node, state = stack.pop
224+
next unless node
225+
226+
if state == :exit
227+
visiting.delete(node.object_id)
228+
next
229+
end
230+
231+
node_id = node.object_id
232+
raise ArgumentError, 'Cycle detected in tree' if visiting.key?(node_id)
233+
next if visited.key?(node_id)
234+
235+
visiting[node_id] = true
236+
visited[node_id] = true
237+
238+
stack << [node, :exit]
239+
node.children.reverse_each do |child|
240+
stack << [child, :enter] if child
241+
end
242+
end
243+
244+
self
245+
end
246+
202247
# @!group Node Creation
203248

204249
# Creates a new node with a name and optional content.

test/test_tree_structure.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,5 +416,29 @@ def test_change_parent
416416
assert_equal('3', root2['3']['4'].parent.name)
417417
assert_nil(root1['2']['4'])
418418
end
419+
420+
def test_validate_acyclic
421+
setup_test_tree
422+
423+
assert_equal(@root, @root.validate_acyclic!)
424+
assert(@root.acyclic?, 'Expected the tree to be acyclic')
425+
end
426+
427+
def test_validate_acyclic_detects_cycle
428+
node_a = Tree::TreeNode.new('A')
429+
node_b = Tree::TreeNode.new('B')
430+
431+
node_a.instance_variable_set(:@children, [node_b])
432+
node_a.instance_variable_set(:@children_hash, { node_b.name => node_b })
433+
node_b.instance_variable_set(:@parent, node_a)
434+
435+
node_b.instance_variable_set(:@children, [node_a])
436+
node_b.instance_variable_set(:@children_hash, { node_a.name => node_a })
437+
node_a.instance_variable_set(:@parent, node_b)
438+
439+
error = assert_raise(ArgumentError) { node_a.validate_acyclic! }
440+
assert_match(/Cycle detected/, error.message)
441+
assert_equal(false, node_a.acyclic?)
442+
end
419443
end
420444
end

0 commit comments

Comments
 (0)