From a7e458793e075b074c2d404a97b7a5f3c13eaeee Mon Sep 17 00:00:00 2001
From: Ivan Yelizariev <iel@odoo.com>
Date: Thu, 21 Apr 2022 14:56:58 +0000
Subject: [PATCH] [FIX] core: fix infinite loops with child_of/parent_of

1.

Infinite loop may happen on using `parent_of`\`child_of` when there is a
recursion in the tree (e.g. a record is marked as a parent of itself). Fix it by
excluding seen records from the next iteration.

2.

Another problem with `child_of` is `parent_id` that references to another model.
For example, the `parent_id` may come from inherited model. It's the case with
`res.users` and `res.partner` models. It may lead to a random search results.
Avoid that by raising exception in case of wrong usage of the `child_of`
operator.

STEPS:

In demo data, there is a partner called "Wood Corner" that is `res.partner(9,)`
that has 3 sub-contacts. If we give Portal access to two of them, we end up with
a database, where we have a `res.users(9,)` record that has a partner, which has a
`parent_id` to "Wood corner". So this way, the user id is the same as the user's
partner's parent contact id.

After that open a shell and type:

```
env['res.partner'].search([["user_ids", "child_of", 9]])
```

BEFORE: infinite loop (without change n.1) or random search results (when change
n.1 is applied)
AFTER: ValueError exception

---

opw-2729740

closes odoo/odoo#122524

X-original-commit: a7352c644f5f12ca075546bd57912f849f9e20f2
Signed-off-by: Raphael Collet <rco@odoo.com>
Signed-off-by: Ivan Elizaryev (iel) <iel@odoo.com>
---
 .../test_new_api/models/test_new_api.py       | 18 +++++++++++++
 .../test_new_api/security/ir.model.access.csv |  2 ++
 .../test_new_api/tests/test_one2many.py       | 25 +++++++++++++++++++
 odoo/osv/expression.py                        |  6 +++--
 4 files changed, 49 insertions(+), 2 deletions(-)

diff --git a/odoo/addons/test_new_api/models/test_new_api.py b/odoo/addons/test_new_api/models/test_new_api.py
index 39fead9f8af6..29ad888c60e3 100644
--- a/odoo/addons/test_new_api/models/test_new_api.py
+++ b/odoo/addons/test_new_api/models/test_new_api.py
@@ -1777,3 +1777,21 @@ class UnlinkLine(models.Model):
 
     container_id = fields.Many2one('test_new_api.unlink.container')
     container_name = fields.Char('Container Name', related='container_id.name', store=True)
+
+
+class Team(models.Model):
+    _name = 'test_new_api.team'
+    _description = 'Odoo Team'
+
+    name = fields.Char()
+    parent_id = fields.Many2one('test_new_api.team')
+    member_ids = fields.One2many('test_new_api.team.member', 'team_id')
+
+
+class TeamMember(models.Model):
+    _name = 'test_new_api.team.member'
+    _description = 'Odoo Developer'
+
+    name = fields.Char('Name')
+    team_id = fields.Many2one('test_new_api.team')
+    parent_id = fields.Many2one('test_new_api.team', related='team_id.parent_id')
diff --git a/odoo/addons/test_new_api/security/ir.model.access.csv b/odoo/addons/test_new_api/security/ir.model.access.csv
index 505a84f01fb6..958c4998666a 100644
--- a/odoo/addons/test_new_api/security/ir.model.access.csv
+++ b/odoo/addons/test_new_api/security/ir.model.access.csv
@@ -105,3 +105,5 @@ access_test_new_api_indexed_translation,access_test_new_api_indexed_translation,
 access_test_new_api_empty_char,access_test_new_api_empty_char,model_test_new_api_empty_char,,1,1,1,1
 access_test_new_api_unlink_container,access_test_new_api_unlink_container,model_test_new_api_unlink_container,,1,1,1,1
 access_test_new_api_unlink_line,access_test_new_api_unlink_line,model_test_new_api_unlink_line,,1,1,1,1
+access_test_new_api_team,access_test_new_api_team,model_test_new_api_team,base.group_user,1,0,0,0
+access_test_new_api_team_member,access_test_new_api_team_member,model_test_new_api_team_member,base.group_user,1,0,0,0
diff --git a/odoo/addons/test_new_api/tests/test_one2many.py b/odoo/addons/test_new_api/tests/test_one2many.py
index 50b7e48b569b..814690f9864e 100644
--- a/odoo/addons/test_new_api/tests/test_one2many.py
+++ b/odoo/addons/test_new_api/tests/test_one2many.py
@@ -409,3 +409,28 @@ class One2manyCase(TransactionCase):
         self.assertEqual(len(parent.child_ids), 3)
         self.assertEqual(parent, parent.child_ids.parent_id)
         self.assertEqual(parent.child_ids.mapped('name'), ['C3', 'PO', 'R2D2'])
+
+    def test_parent_id(self):
+        Team = self.env['test_new_api.team']
+        Member = self.env['test_new_api.team.member']
+
+        team1 = Team.create({'name': 'ORM'})
+        team2 = Team.create({'name': 'Bugfix', 'parent_id': team1.id})
+        team3 = Team.create({'name': 'Support', 'parent_id': team2.id})
+
+        Member.create({'name': 'Raphael', 'team_id': team1.id})
+        member2 = Member.create({'name': 'Noura', 'team_id': team3.id})
+        Member.create({'name': 'Ivan', 'team_id': team2.id})
+
+        # In this specific case...
+        self.assertEqual(member2.id, member2.team_id.parent_id.id)
+
+        # ...we had an infinite recursion on making the following search.
+        with self.assertRaises(ValueError):
+            Team.search([('member_ids', 'child_of', member2.id)])
+
+        # Also, test a simple infinite loop if record is marked as a parent of itself
+        team1.parent_id = team1.id
+        # Check that the search is not stuck in the loop
+        Team.search([('id', 'parent_of', team1.id)])
+        Team.search([('id', 'child_of', team1.id)])
diff --git a/odoo/osv/expression.py b/odoo/osv/expression.py
index 11cab35b7a75..770fdef89738 100644
--- a/odoo/osv/expression.py
+++ b/odoo/osv/expression.py
@@ -557,11 +557,13 @@ class expression(object):
                 # filtering of forbidden records is done by the rest of the
                 # domain
                 parent_name = parent or left_model._parent_name
+                if (left_model._name != left_model._fields[parent_name].comodel_name):
+                    raise ValueError(f"Invalid parent field: {left_model._fields[parent_name]}")
                 child_ids = set()
                 records = left_model.sudo().browse(ids)
                 while records:
                     child_ids.update(records._ids)
-                    records = records.search([(parent_name, 'in', records.ids)], order='id')
+                    records = records.search([(parent_name, 'in', records.ids)], order='id') - records.browse(child_ids)
                 domain = [('id', 'in', list(child_ids))]
             if prefix:
                 return [(left, 'in', left_model._search(domain, order='id'))]
@@ -589,7 +591,7 @@ class expression(object):
                 records = left_model.sudo().browse(ids)
                 while records:
                     parent_ids.update(records._ids)
-                    records = records[parent_name]
+                    records = records[parent_name] - records.browse(parent_ids)
                 domain = [('id', 'in', list(parent_ids))]
             if prefix:
                 return [(left, 'in', left_model._search(domain, order='id'))]
-- 
GitLab