[FIX] anonymize : passer la liste noire entière sans échec ni omission
Le SQL de la liste noire dépasse MAX_ARG_STRLEN, les 131 072 octets que Linux impose à UN argument ; il passe par un fichier avec -f, qui garde --single-transaction que l'entrée standard aurait perdu. Les colonnes texte à longueur déclarée sont tronquées par left(..., n), l'identifiant en TÊTE sur une colonne unique. Tous les identifiants sont cités : Odoo laisse nommer un champ user ou order. Écarter toute colonne sous CHECK laissait res_partner.name intact ; sur du texte, seules les contraintes de FORME sont hors de portée. Vérifié sur les quatre cas ; un UPDATE qui échoue n'écrit rien. --- EN --- The blacklist SQL exceeds MAX_ARG_STRLEN, the 131 072 bytes Linux allows for ONE argument; it travels through a file with -f, which keeps --single-transaction that stdin would have dropped. Text columns with a declared length are truncated by left(..., n), the id FIRST on a unique column. Every identifier is quoted: Odoo allows a field named user or order. Skipping every column under a CHECK left res_partner.name untouched; on text, only FORM constraints are out of reach. Checked on the four cases; an UPDATE that fails writes nothing. Assisted-by: Claude Opus 5 (cherry picked from commit 610f1d30414d578b49bff5649dbda82f4a2eb19f)
This commit is contained in:
parent
fe52ad9974
commit
13a7bca972
2 changed files with 350 additions and 38 deletions
|
|
@ -198,11 +198,16 @@ def champ_retenu(champ, inclure_connexion=False):
|
||||||
# Une relation qui aurait échappé au filtre de ttype.
|
# Une relation qui aurait échappé au filtre de ttype.
|
||||||
return False
|
return False
|
||||||
if champ.get("checked"):
|
if champ.get("checked"):
|
||||||
# Une contrainte CHECK dit ce que la colonne a le droit de valoir.
|
# Une contrainte CHECK hors de portée. Mesuré, et la distinction
|
||||||
# Mesuré : crm_lead.probability doit rester entre 0 et 100, et un
|
# compte : sur un NOMBRE toute contrainte borne la valeur —
|
||||||
# tirage à 1000 fait échouer l'UPDATE — donc, transaction unique
|
# crm_lead.probability entre 0 et 100, credit * debit = 0 — et un
|
||||||
# oblige, TOUTE l'anonymisation. Lire l'expression du CHECK pour
|
# tirage à 1000 fait échouer l'UPDATE, donc toute l'anonymisation.
|
||||||
# tirer dedans serait deviner ; on s'abstient et on le dit.
|
# Sur du TEXTE, presque toutes ne garantissent que la non-nullité :
|
||||||
|
# `res_partner.name` n'exige d'être non nul que pour un contact, ce
|
||||||
|
# qu'un mot satisfait. Les écarter TOUTES laissait le champ le plus
|
||||||
|
# important de la base intact — une anonymisation qui n'anonymisait
|
||||||
|
# pas les noms. La requête ne lève donc ce drapeau, pour du texte,
|
||||||
|
# que sur les contraintes de FORME.
|
||||||
return False
|
return False
|
||||||
if champ["ttype"] in TYPES_NOMBRE and champ.get("pg_type") == "jsonb":
|
if champ["ttype"] in TYPES_NOMBRE and champ.get("pg_type") == "jsonb":
|
||||||
# Mesuré sur res_partner.credit_limit : un `float` d'Odoo peut
|
# Mesuré sur res_partner.credit_limit : un `float` d'Odoo peut
|
||||||
|
|
@ -233,22 +238,46 @@ def litteral(texte):
|
||||||
return "'" + str(texte).replace("'", "''") + "'"
|
return "'" + str(texte).replace("'", "''") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
def ident(nom):
|
||||||
|
"""Un identifiant SQL, cité.
|
||||||
|
|
||||||
|
Odoo laisse nommer un champ `user`, `order` ou `group` : ce sont des
|
||||||
|
mots réservés de PostgreSQL, et un identifiant nu fait échouer
|
||||||
|
l'analyse syntaxique — donc, transaction unique oblige, TOUTE
|
||||||
|
l'anonymisation. Mesuré en liste noire sur une base réelle :
|
||||||
|
« syntax error at or near "user" ». Les citer coûte deux caractères
|
||||||
|
et ferme la question pour tous les noms à venir.
|
||||||
|
"""
|
||||||
|
return '"' + str(nom).replace('"', '""') + '"'
|
||||||
|
|
||||||
|
|
||||||
def expression_texte(champ, mots):
|
def expression_texte(champ, mots):
|
||||||
"""Le SQL qui remplace un champ texte, en préservant les NULL.
|
"""Le SQL qui remplace un champ texte, en préservant les NULL.
|
||||||
|
|
||||||
Un NULL qui deviendrait un mot créerait de la donnée là où il n'y en
|
Un NULL qui deviendrait un mot créerait de la donnée là où il n'y en
|
||||||
avait pas : la copie mentirait dans l'autre sens.
|
avait pas : la copie mentirait dans l'autre sens.
|
||||||
"""
|
"""
|
||||||
nom = champ["name"]
|
nom = ident(champ["name"])
|
||||||
liste = mots_pour(nom, mots)
|
liste = mots_pour(champ["name"], mots)
|
||||||
# Les parenthèses ne sont pas décoratives : PostgreSQL refuse
|
# Les parenthèses ne sont pas décoratives : PostgreSQL refuse
|
||||||
# d'indexer un constructeur ARRAY[...] directement.
|
# d'indexer un constructeur ARRAY[...] directement.
|
||||||
tableau = "(ARRAY[" + ",".join(litteral(m) for m in liste) + "])"
|
tableau = "(ARRAY[" + ",".join(litteral(m) for m in liste) + "])"
|
||||||
tirage = f"{tableau}[(id % {len(liste)}) + 1]"
|
tirage = f'{tableau}[("id" % {len(liste)}) + 1]'
|
||||||
|
borne = champ.get("max_len")
|
||||||
if champ.get("unique"):
|
if champ.get("unique"):
|
||||||
# Deux lignes qui reçoivent le même mot feraient échouer TOUT
|
# Deux lignes qui reçoivent le même mot feraient échouer TOUT
|
||||||
# l'UPDATE sur une colonne unique.
|
# l'UPDATE sur une colonne unique. L'identifiant vient EN TÊTE
|
||||||
tirage = f"{tirage} || '-' || id::text"
|
# quand la colonne est bornée : c'est lui qui porte l'unicité, et
|
||||||
|
# une troncature par la droite doit le laisser intact.
|
||||||
|
if borne:
|
||||||
|
tirage = f"\"id\"::text || '-' || {tirage}"
|
||||||
|
else:
|
||||||
|
tirage = f"{tirage} || '-' || \"id\"::text"
|
||||||
|
if borne:
|
||||||
|
# varchar(n) : mesuré, 13 colonnes sont bornées sur une base
|
||||||
|
# réelle, dont des codes à 1, 2 et 3 caractères. « jonquille »
|
||||||
|
# dans un varchar(3) fait échouer l'UPDATE entier.
|
||||||
|
tirage = f"left({tirage}, {borne})"
|
||||||
if champ.get("pg_type") == "jsonb":
|
if champ.get("pg_type") == "jsonb":
|
||||||
# Un objet par langue depuis Odoo 17 : on le reconstruit clé à
|
# Un objet par langue depuis Odoo 17 : on le reconstruit clé à
|
||||||
# clé. Écrire une chaîne par-dessus détruirait la colonne.
|
# clé. Écrire une chaîne par-dessus détruirait la colonne.
|
||||||
|
|
@ -262,7 +291,7 @@ def expression_texte(champ, mots):
|
||||||
|
|
||||||
def expression_nombre(champ):
|
def expression_nombre(champ):
|
||||||
"""Le SQL qui remplace un nombre : au hasard, entre 0 et 1000."""
|
"""Le SQL qui remplace un nombre : au hasard, entre 0 et 1000."""
|
||||||
nom = champ["name"]
|
nom = ident(champ["name"])
|
||||||
if champ["ttype"] == "integer":
|
if champ["ttype"] == "integer":
|
||||||
tirage = "floor(random() * 1001)::integer"
|
tirage = "floor(random() * 1001)::integer"
|
||||||
else:
|
else:
|
||||||
|
|
@ -275,14 +304,13 @@ def sql_pour_table(table, champs, mots):
|
||||||
morceaux = []
|
morceaux = []
|
||||||
for champ in champs:
|
for champ in champs:
|
||||||
if champ["ttype"] in TYPES_TEXTE:
|
if champ["ttype"] in TYPES_TEXTE:
|
||||||
morceaux.append(
|
valeur = expression_texte(champ, mots)
|
||||||
f"{champ['name']} = {expression_texte(champ, mots)}"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
morceaux.append(f"{champ['name']} = {expression_nombre(champ)}")
|
valeur = expression_nombre(champ)
|
||||||
|
morceaux.append(f"{ident(champ['name'])} = {valeur}")
|
||||||
if not morceaux:
|
if not morceaux:
|
||||||
return None
|
return None
|
||||||
return f"UPDATE {table} SET " + ", ".join(morceaux) + ";"
|
return f"UPDATE {ident(table)} SET " + ", ".join(morceaux) + ";"
|
||||||
|
|
||||||
|
|
||||||
def table_de(modele):
|
def table_de(modele):
|
||||||
|
|
@ -311,7 +339,19 @@ SELECT f.model || '\x1f' || f.name || '\x1f' || f.ttype || '\x1f'
|
||||||
WHERE k.conrelid = c.oid
|
WHERE k.conrelid = c.oid
|
||||||
AND k.contype = 'c'
|
AND k.contype = 'c'
|
||||||
AND a.attnum = ANY(k.conkey)
|
AND a.attnum = ANY(k.conkey)
|
||||||
) THEN '1' ELSE '0' END
|
AND (
|
||||||
|
-- Sur un NOMBRE, toute contrainte borne la valeur :
|
||||||
|
-- `credit * debit = 0`, `amount >= 0`. On s'abstient.
|
||||||
|
f.ttype IN ('integer','float','monetary')
|
||||||
|
-- Sur du TEXTE, presque toutes ne garantissent que la
|
||||||
|
-- non-nullité, ce qu'un mot satisfait. Seules celles
|
||||||
|
-- qui contraignent la FORME sont hors de portée.
|
||||||
|
OR pg_get_constraintdef(k.oid) ~
|
||||||
|
'char_length|~~|jsonb_typeof|similar to'
|
||||||
|
)
|
||||||
|
) THEN '1' ELSE '0' END || '\x1f'
|
||||||
|
|| CASE WHEN a.atttypmod > 4
|
||||||
|
THEN (a.atttypmod - 4)::text ELSE '' END
|
||||||
FROM ir_model_fields f
|
FROM ir_model_fields f
|
||||||
JOIN pg_class c ON c.relname = replace(f.model, '.', '_')
|
JOIN pg_class c ON c.relname = replace(f.model, '.', '_')
|
||||||
AND c.relkind = 'r'
|
AND c.relkind = 'r'
|
||||||
|
|
@ -333,7 +373,7 @@ def inspect(database, config_path=None):
|
||||||
champs = []
|
champs = []
|
||||||
for ligne in brut.splitlines():
|
for ligne in brut.splitlines():
|
||||||
parts = ligne.split(SEP)
|
parts = ligne.split(SEP)
|
||||||
if len(parts) != 6:
|
if len(parts) != 7:
|
||||||
continue
|
continue
|
||||||
champs.append(
|
champs.append(
|
||||||
{
|
{
|
||||||
|
|
@ -343,6 +383,11 @@ def inspect(database, config_path=None):
|
||||||
"pg_type": parts[3],
|
"pg_type": parts[3],
|
||||||
"unique": parts[4] == "1",
|
"unique": parts[4] == "1",
|
||||||
"checked": parts[5] == "1",
|
"checked": parts[5] == "1",
|
||||||
|
# varchar(n) : n, sinon None. Mesuré sur une base réelle,
|
||||||
|
# 13 colonnes sont bornées — dont des codes à 1, 2 et 3
|
||||||
|
# caractères. Y écrire « jonquille » fait échouer tout
|
||||||
|
# l'UPDATE, et donc toute l'anonymisation.
|
||||||
|
"max_len": int(parts[6]) if parts[6].isdigit() else None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return champs
|
return champs
|
||||||
|
|
@ -446,29 +491,56 @@ def ecrire(database, etapes, config_path=None, timeout=900):
|
||||||
redéclarer ici, ce serait accepter qu'elle diverge un jour.
|
redéclarer ici, ce serait accepter qu'elle diverge un jour.
|
||||||
"""
|
"""
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
env = lib_analyse.pg_env(config_path, timeout=timeout)
|
env = lib_analyse.pg_env(config_path, timeout=timeout)
|
||||||
env["PGOPTIONS"] = f"-c statement_timeout={timeout}s"
|
env["PGOPTIONS"] = f"-c statement_timeout={timeout}s"
|
||||||
sql = "\n".join(etape["sql"] for etape in etapes)
|
sql = "\n".join(etape["sql"] for etape in etapes)
|
||||||
done = subprocess.run(
|
|
||||||
[
|
# PAR FICHIER, jamais par `-c`. Linux plafonne un seul argument à
|
||||||
"psql",
|
# MAX_ARG_STRLEN — 32 pages, soit 131 072 octets. Mesuré sur une base
|
||||||
"-X",
|
# réelle : le mode hybride tient dans 58 Ko et passait, la liste noire
|
||||||
"-w",
|
# produit 342 Ko sur 410 modèles et rendait « OSError: [Errno 7]
|
||||||
"-1",
|
# Argument list too long ». Le mode qui couvre le plus est justement
|
||||||
"-v",
|
# celui qui cassait.
|
||||||
"ON_ERROR_STOP=1",
|
#
|
||||||
"-d",
|
# `-f` plutôt que l'entrée standard : `--single-transaction` n'est
|
||||||
database,
|
# documenté qu'avec `-c` ou `-f`, et c'est lui qui garantit le tout
|
||||||
"-tA",
|
# ou rien. Le perdre en silence serait pire que le message d'erreur.
|
||||||
"-c",
|
handle = tempfile.NamedTemporaryFile(
|
||||||
sql,
|
mode="w",
|
||||||
],
|
suffix=".sql",
|
||||||
capture_output=True,
|
prefix="el_anonymize_",
|
||||||
text=True,
|
encoding="utf-8",
|
||||||
env=env,
|
delete=False,
|
||||||
timeout=timeout + 60,
|
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
handle.write(sql)
|
||||||
|
handle.close()
|
||||||
|
done = subprocess.run(
|
||||||
|
[
|
||||||
|
"psql",
|
||||||
|
"-X",
|
||||||
|
"-w",
|
||||||
|
"-1",
|
||||||
|
"-v",
|
||||||
|
"ON_ERROR_STOP=1",
|
||||||
|
"-d",
|
||||||
|
database,
|
||||||
|
"-tA",
|
||||||
|
"-f",
|
||||||
|
handle.name,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=env,
|
||||||
|
timeout=timeout + 60,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(handle.name)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
if done.returncode:
|
if done.returncode:
|
||||||
detail = (done.stderr or "").strip().splitlines()
|
detail = (done.stderr or "").strip().splitlines()
|
||||||
return detail[0][:200] if detail else "psql"
|
return detail[0][:200] if detail else "psql"
|
||||||
|
|
|
||||||
|
|
@ -178,9 +178,9 @@ class TestTheSqlItWrites(unittest.TestCase):
|
||||||
def test_a_unique_column_gets_the_id_appended(self):
|
def test_a_unique_column_gets_the_id_appended(self):
|
||||||
"""Deux lignes au même mot feraient échouer TOUT l'UPDATE."""
|
"""Deux lignes au même mot feraient échouer TOUT l'UPDATE."""
|
||||||
sql = anon.expression_texte(champ("ref", unique=True), ["a"])
|
sql = anon.expression_texte(champ("ref", unique=True), ["a"])
|
||||||
self.assertIn("id::text", sql)
|
self.assertIn('"id"::text', sql)
|
||||||
self.assertNotIn(
|
self.assertNotIn(
|
||||||
"id::text", anon.expression_texte(champ("ref"), ["a"])
|
'"id"::text', anon.expression_texte(champ("ref"), ["a"])
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_a_translated_column_is_rebuilt_key_by_key(self):
|
def test_a_translated_column_is_rebuilt_key_by_key(self):
|
||||||
|
|
@ -366,3 +366,243 @@ class TestTheRefusalToWrite(unittest.TestCase):
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheSqlNeverTravelsThroughArgv(unittest.TestCase):
|
||||||
|
"""La panne signalée : « OSError: [Errno 7] Argument list too long ».
|
||||||
|
|
||||||
|
Linux plafonne UN SEUL argument à MAX_ARG_STRLEN — 32 pages, soit
|
||||||
|
131 072 octets. Mesuré sur une base réelle : le mode hybride produit
|
||||||
|
58 Ko de SQL et passait, la liste noire en produit 342 Ko sur 410
|
||||||
|
modèles et cassait. Le mode qui couvre le plus était celui qui
|
||||||
|
échouait, donc celui qu'aucun de mes essais n'exerçait.
|
||||||
|
|
||||||
|
Le rendu de `render` reste borné, lui ; c'est bien l'exécution qu'il
|
||||||
|
faut regarder, et pas seulement le plan.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _executer(self, etapes):
|
||||||
|
"""Lancer `ecrire` avec un faux psql, et rendre ce qu'il a reçu."""
|
||||||
|
import script.analyse.anonymize as module
|
||||||
|
|
||||||
|
vu = {}
|
||||||
|
vrai_run = module.__dict__.get("subprocess")
|
||||||
|
|
||||||
|
class FauxFait:
|
||||||
|
returncode = 0
|
||||||
|
stdout = ""
|
||||||
|
stderr = ""
|
||||||
|
|
||||||
|
import subprocess as vrai_subprocess
|
||||||
|
|
||||||
|
def espion(cmd, **kwargs):
|
||||||
|
vu["cmd"] = list(cmd)
|
||||||
|
chemin = cmd[cmd.index("-f") + 1] if "-f" in cmd else None
|
||||||
|
if chemin:
|
||||||
|
with open(chemin, encoding="utf-8") as handle:
|
||||||
|
vu["fichier"] = handle.read()
|
||||||
|
vu["chemin"] = chemin
|
||||||
|
return FauxFait()
|
||||||
|
|
||||||
|
vrai_env = anon.lib_analyse.pg_env
|
||||||
|
anon.lib_analyse.pg_env = lambda *a, **k: {"PATH": "/usr/bin"}
|
||||||
|
vrai_subprocess_run = vrai_subprocess.run
|
||||||
|
vrai_subprocess.run = espion
|
||||||
|
try:
|
||||||
|
erreur = anon.ecrire("une_base", etapes)
|
||||||
|
finally:
|
||||||
|
vrai_subprocess.run = vrai_subprocess_run
|
||||||
|
anon.lib_analyse.pg_env = vrai_env
|
||||||
|
del vrai_run
|
||||||
|
vu["erreur"] = erreur
|
||||||
|
return vu
|
||||||
|
|
||||||
|
def _gros_plan(self, combien=400):
|
||||||
|
"""Un plan de la taille de celui qui cassait."""
|
||||||
|
etapes = []
|
||||||
|
for index in range(combien):
|
||||||
|
champ = {
|
||||||
|
"model": "m.%d" % index,
|
||||||
|
"name": "name",
|
||||||
|
"ttype": "char",
|
||||||
|
"pg_type": "character varying",
|
||||||
|
"unique": False,
|
||||||
|
"checked": False,
|
||||||
|
}
|
||||||
|
etapes.append(
|
||||||
|
{
|
||||||
|
"model": champ["model"],
|
||||||
|
"fields": [champ],
|
||||||
|
"sql": anon.sql_pour_table("m_%d" % index, [champ], None),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return etapes
|
||||||
|
|
||||||
|
def test_no_single_argument_comes_close_to_the_kernel_limit(self):
|
||||||
|
vu = self._executer(self._gros_plan())
|
||||||
|
plus_gros = max(len(a) for a in vu["cmd"])
|
||||||
|
self.assertLess(
|
||||||
|
plus_gros,
|
||||||
|
4096,
|
||||||
|
"un argument porte le SQL : c'est ce qui rendait E2BIG",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_the_sql_goes_through_a_file_not_through_c(self):
|
||||||
|
vu = self._executer(self._gros_plan(3))
|
||||||
|
self.assertIn("-f", vu["cmd"])
|
||||||
|
self.assertNotIn("-c", vu["cmd"])
|
||||||
|
self.assertIn('UPDATE "m_0"', vu["fichier"])
|
||||||
|
|
||||||
|
def test_the_single_transaction_survives_the_change(self):
|
||||||
|
"""`--single-transaction` n'est documenté qu'avec -c ou -f : passer
|
||||||
|
par l'entrée standard l'aurait perdu en silence."""
|
||||||
|
vu = self._executer(self._gros_plan(2))
|
||||||
|
self.assertIn("-1", vu["cmd"])
|
||||||
|
self.assertIn("ON_ERROR_STOP=1", vu["cmd"])
|
||||||
|
|
||||||
|
def test_the_temporary_file_does_not_survive(self):
|
||||||
|
import os as vrai_os
|
||||||
|
|
||||||
|
vu = self._executer(self._gros_plan(2))
|
||||||
|
self.assertFalse(vrai_os.path.exists(vu["chemin"]))
|
||||||
|
|
||||||
|
|
||||||
|
class TestABoundedColumnIsNeverOverflowed(unittest.TestCase):
|
||||||
|
"""`value too long for type character varying(3)`.
|
||||||
|
|
||||||
|
Mesuré sur une base réelle : 13 colonnes texte portent une longueur
|
||||||
|
déclarée, dont des codes à 1, 2 et 3 caractères — `res.country.code`,
|
||||||
|
`account.journal.code`. Y écrire « jonquille » fait échouer l'UPDATE,
|
||||||
|
et comme l'écriture est transactionnelle, TOUTE l'anonymisation.
|
||||||
|
|
||||||
|
Le mode hybride ne touchait aucune de ces colonnes ; la liste noire,
|
||||||
|
si. Le mode qui couvre le plus est celui qui cassait.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _champ(self, **kw):
|
||||||
|
base = {
|
||||||
|
"model": "m",
|
||||||
|
"name": "code",
|
||||||
|
"ttype": "char",
|
||||||
|
"pg_type": "character varying",
|
||||||
|
"unique": False,
|
||||||
|
"checked": False,
|
||||||
|
"max_len": None,
|
||||||
|
}
|
||||||
|
base.update(kw)
|
||||||
|
return base
|
||||||
|
|
||||||
|
def test_a_bounded_column_is_truncated(self):
|
||||||
|
sql = anon.expression_texte(self._champ(max_len=3), ["jonquille"])
|
||||||
|
self.assertIn("left(", sql)
|
||||||
|
self.assertIn(", 3)", sql)
|
||||||
|
|
||||||
|
def test_an_unbounded_column_is_left_alone(self):
|
||||||
|
sql = anon.expression_texte(self._champ(), ["jonquille"])
|
||||||
|
self.assertNotIn("left(", sql)
|
||||||
|
|
||||||
|
def test_a_bounded_unique_column_keeps_the_id_in_front(self):
|
||||||
|
"""Tronquer par la droite doit laisser l'identifiant intact :
|
||||||
|
c'est lui qui porte l'unicité."""
|
||||||
|
sql = anon.expression_texte(
|
||||||
|
self._champ(max_len=8, unique=True), ["jonquille"]
|
||||||
|
)
|
||||||
|
self.assertIn("left(\"id\"::text || '-'", sql)
|
||||||
|
|
||||||
|
def test_an_unbounded_unique_column_keeps_the_old_shape(self):
|
||||||
|
sql = anon.expression_texte(self._champ(unique=True), ["jonquille"])
|
||||||
|
self.assertTrue(sql.rstrip().endswith("|| '-' || \"id\"::text END"))
|
||||||
|
|
||||||
|
def test_the_length_is_read_from_the_database_not_guessed(self):
|
||||||
|
"""`atttypmod` est la seule source : une longueur devinée serait
|
||||||
|
fausse dès qu'un module en change une."""
|
||||||
|
self.assertIn("atttypmod", anon.REQUETE_CHAMPS)
|
||||||
|
|
||||||
|
|
||||||
|
class TestReservedWordsCannotBreakTheStatement(unittest.TestCase):
|
||||||
|
"""`syntax error at or near "user"`.
|
||||||
|
|
||||||
|
Odoo laisse nommer un champ `user`, `order` ou `group`. Un identifiant
|
||||||
|
nu fait alors échouer l'analyse syntaxique — et l'écriture étant
|
||||||
|
transactionnelle, c'est toute l'anonymisation qui tombe. Trouvé en
|
||||||
|
liste noire sur une base réelle, jamais en mode hybride : les quinze
|
||||||
|
modèles par défaut n'en portent aucun.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _champ(self, nom):
|
||||||
|
return {
|
||||||
|
"model": "m",
|
||||||
|
"name": nom,
|
||||||
|
"ttype": "char",
|
||||||
|
"pg_type": "character varying",
|
||||||
|
"unique": False,
|
||||||
|
"checked": False,
|
||||||
|
"max_len": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_a_column_named_like_a_keyword_is_quoted(self):
|
||||||
|
for nom in ("user", "order", "group", "check", "references", "limit"):
|
||||||
|
sql = anon.sql_pour_table("t", [self._champ(nom)], ["a"])
|
||||||
|
self.assertIn(f'"{nom}" =', sql, nom)
|
||||||
|
self.assertNotIn(f" {nom} =", sql, nom)
|
||||||
|
|
||||||
|
def test_a_table_named_like_a_keyword_is_quoted(self):
|
||||||
|
sql = anon.sql_pour_table("order", [self._champ("name")], ["a"])
|
||||||
|
self.assertTrue(sql.startswith('UPDATE "order" SET'))
|
||||||
|
|
||||||
|
def test_the_row_identifier_is_quoted_too(self):
|
||||||
|
"""Une seule règle vaut mieux que deux : tout identifiant est cité."""
|
||||||
|
sql = anon.expression_texte(self._champ("name"), ["a", "b"])
|
||||||
|
self.assertIn('"id"', sql)
|
||||||
|
|
||||||
|
def test_a_quote_inside_an_identifier_cannot_escape(self):
|
||||||
|
self.assertEqual(anon.ident('a"b'), '"a""b"')
|
||||||
|
|
||||||
|
|
||||||
|
class TestACheckDoesNotSilenceTheMainField(unittest.TestCase):
|
||||||
|
"""La règle « écarter toute colonne sous CHECK » était trop large.
|
||||||
|
|
||||||
|
Mesuré : `res_partner.name` porte
|
||||||
|
CHECK ((type='contact' AND name IS NOT NULL) OR type<>'contact')
|
||||||
|
— une garantie de non-nullité, qu'un mot satisfait. L'écarter rendait
|
||||||
|
une anonymisation qui n'anonymisait pas les noms, en annonçant 255
|
||||||
|
colonnes écrites. Le pire des deux mondes : silencieux et faux.
|
||||||
|
|
||||||
|
Sur un NOMBRE la distinction s'inverse : `credit * debit = 0` et
|
||||||
|
`amount >= 0` bornent la valeur, et un tirage à 1000 les viole.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_the_query_tells_numbers_from_text(self):
|
||||||
|
"""La règle vit dans le SQL : c'est là qu'elle se vérifie."""
|
||||||
|
requete = anon.REQUETE_CHAMPS
|
||||||
|
self.assertIn("f.ttype IN ('integer','float','monetary')", requete)
|
||||||
|
self.assertIn("pg_get_constraintdef", requete)
|
||||||
|
|
||||||
|
def test_only_shape_constraints_disqualify_text(self):
|
||||||
|
for motif in ("char_length", "~~", "jsonb_typeof"):
|
||||||
|
self.assertIn(motif, anon.REQUETE_CHAMPS, motif)
|
||||||
|
|
||||||
|
def test_a_field_the_query_cleared_is_anonymised(self):
|
||||||
|
"""`checked=False` doit suffire : aucune seconde barrière cachée."""
|
||||||
|
champ = {
|
||||||
|
"model": "res.partner",
|
||||||
|
"name": "name",
|
||||||
|
"ttype": "char",
|
||||||
|
"pg_type": "character varying",
|
||||||
|
"unique": False,
|
||||||
|
"checked": False,
|
||||||
|
"max_len": None,
|
||||||
|
}
|
||||||
|
self.assertTrue(anon.champ_retenu(champ))
|
||||||
|
|
||||||
|
def test_a_field_the_query_flagged_is_left_alone(self):
|
||||||
|
champ = {
|
||||||
|
"model": "account.move.line",
|
||||||
|
"name": "credit",
|
||||||
|
"ttype": "monetary",
|
||||||
|
"pg_type": "numeric",
|
||||||
|
"unique": False,
|
||||||
|
"checked": True,
|
||||||
|
"max_len": None,
|
||||||
|
}
|
||||||
|
self.assertFalse(anon.champ_retenu(champ))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue