feat(atuq): la boveda de la suite guarda las contrasenas, y el gestor de Gecko se aparta
La decima extension de atuq. Ofrece la credencial que corresponde al sitio abierto y la pone en el formulario, pero NO puede sacar una contrasena de la boveda por su cuenta: pregunta cuales corresponden —y eso contesta titulo y usuario, nada mas— y la contrasena sale por otro verbo que pregunta en el escritorio antes de contestar. Si la extension queda comprometida, o si una pagina consigue hablarle, lo que obtiene es la lista de titulos de los sitios que coinciden con su propia direccion. La direccion siempre la pone el chrome y nunca la pagina. Lo unico que el trozo que corre dentro del sitio aporta es que HAY un formulario y lo que el usuario tipeo; si pudiera decir de quien es la pagina, podria decir que es el banco. Y la extension no decide nada: quien sabe que credencial va en que sitio es la funcion del otro lado del cable. Ponerlo en JS seria reimplementar el original y, peor, poner en la pagina la decision de a quien se le entrega una contrasena. EL GESTOR DE GECKO SE APAGA, pero como valor de arranque y no como politica. Dos gestores peleando por el mismo campo es la peor experiencia que puede tener alguien que solo quiere entrar a su correo: el sitio se rellena dos veces, o ninguna, y no hay forma de saber cual tiene la buena. De fabrica manda la boveda; el que prefiera el de Gecko lo prende y listo. Prohibirselo seria decidir por el, que es lo que este fichero dice en su cabecera que atuq no quiere ser. Y un guardian nuevo, que corre en un segundo y sin construir nada. Una extension esta enchufada en CUATRO lugares distintos y ninguno da error si falta: el identificador que ella declara, la politica que la instala, el permiso para hablarle al host, y las preferencias que la acompanan. Si falta la politica no se instala. Si falta el permiso se instala y se conecta a nada, en silencio, y la insignia no aparece nunca. Si faltan las preferencias los dos gestores se pelean. Ninguno de los tres se ve como un error: se ven como "no anda". El guardian los mira todos y trae su control negativo, que le saca el permiso del host y exige que lo detecte. No reemplaza al guardian de metal —servidor, navegador real, login real, el dialogo a la vista—: lo precede, y ahorra descubrir construyendo que faltaba un renglon en un JSON. Los cinco verbos del otro lado ya estan en tawasuyu (bc6903f9e), con trece tests que leen el cable.
This commit is contained in:
@@ -113,3 +113,21 @@ try {
|
||||
// nada. Queda en la consola de errores, que es donde se mira cuando «atuq se ve como Firefox».
|
||||
Components.utils.reportError("atuq.cfg: no pude registrar chrome/atuq.css: " + e);
|
||||
}
|
||||
|
||||
// ── LA BÓVEDA, Y EL GESTOR QUE SE APAGA PARA DEJARLE LUGAR (SDD-BOVEDA D7) ─────────────────────
|
||||
// Las contraseñas las guarda la suite: la bóveda las ofrece en el sitio que corresponde, las
|
||||
// sincroniza entre los equipos de la persona y se puede recuperar si se pierde el equipo. Gecko
|
||||
// trae su propio gestor, y DOS gestores peleando por el mismo campo es la peor experiencia que
|
||||
// puede tener alguien que sólo quiere entrar a su correo: el sitio se rellena dos veces, o ninguna,
|
||||
// y no hay forma de saber cuál de los dos tiene la contraseña buena.
|
||||
//
|
||||
// Por eso de fábrica manda la bóveda. Pero va como `defaultPref` y NO como política: el que
|
||||
// prefiera el de Gecko lo prende y listo. Prohibírselo sería decidir por él, que es exactamente lo
|
||||
// que este fichero dice en su cabecera que atuq no quiere ser — y el costo de equivocarse acá lo
|
||||
// paga alguien que se queda sin poder guardar una contraseña en su propia máquina.
|
||||
defaultPref("signon.rememberSignons", false);
|
||||
defaultPref("signon.autofillForms", false);
|
||||
// Y que tampoco ofrezca la que ya tenga guardada de antes: el usuario que migra sus claves con
|
||||
// `foreign-mozlogins` se las lleva a la bóveda, y ver las dos listas a la vez es confuso sin
|
||||
// motivo.
|
||||
defaultPref("signon.autofillForms.autocompleteOff", false);
|
||||
|
||||
@@ -63,6 +63,10 @@
|
||||
"ia@atuq.tawasuyu": {
|
||||
"installation_mode": "normal_installed",
|
||||
"install_url": "file:///usr/lib/atuq/distribution/extensions/ia@atuq.tawasuyu.xpi"
|
||||
},
|
||||
"boveda@atuq.tawasuyu": {
|
||||
"installation_mode": "normal_installed",
|
||||
"install_url": "file:///usr/lib/atuq/distribution/extensions/boveda@atuq.tawasuyu.xpi"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"use strict";
|
||||
// bóveda · campos — lo único que corre DENTRO de la página.
|
||||
//
|
||||
// Su trabajo es mínimo a propósito, porque es la parte que comparte proceso con
|
||||
// un sitio cualquiera: avisa que hay un formulario de acceso, pone lo que el
|
||||
// fondo le manda, y avisa cuando el usuario envió algo.
|
||||
//
|
||||
// **No decide nada y no dice de qué sitio es.** La dirección la pone el chrome
|
||||
// (`sender.tab.url`), no este archivo: si la página pudiera decir quién es,
|
||||
// podría decir que es el banco.
|
||||
|
||||
function campoClave() {
|
||||
return document.querySelector('input[type="password"]:not([disabled])');
|
||||
}
|
||||
|
||||
function campoUsuario(clave) {
|
||||
// El de usuario es el campo de texto anterior al de contraseña en el mismo
|
||||
// formulario. Es una heurística, y por eso rellenarlo NO es obligatorio: si
|
||||
// no se encuentra, se pone sólo la contraseña, que es lo que importa.
|
||||
const f = clave && clave.form;
|
||||
const candidatos = (f || document).querySelectorAll(
|
||||
'input[type="text"], input[type="email"], input:not([type])'
|
||||
);
|
||||
let ultimo = null;
|
||||
for (const c of candidatos) {
|
||||
if (clave && c.compareDocumentPosition(clave) & Node.DOCUMENT_POSITION_FOLLOWING) {
|
||||
ultimo = c;
|
||||
}
|
||||
}
|
||||
return ultimo;
|
||||
}
|
||||
|
||||
function poner(campo, valor) {
|
||||
if (!campo) { return; }
|
||||
campo.focus();
|
||||
campo.value = valor;
|
||||
// Los dos eventos: los formularios modernos escuchan `input`, y algunos
|
||||
// validadores sólo `change`. Sin esto el sitio cree que el campo está vacío.
|
||||
campo.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
campo.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
|
||||
browser.runtime.onMessage.addListener(msg => {
|
||||
if (!msg || msg.tipo !== "rellenar") { return; }
|
||||
const clave = campoClave();
|
||||
if (!clave) { return; }
|
||||
poner(campoUsuario(clave), msg.username);
|
||||
poner(clave, msg.password);
|
||||
});
|
||||
|
||||
function avisarSiHay() {
|
||||
if (campoClave()) {
|
||||
browser.runtime.sendMessage({ tipo: "hay-formulario" });
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("submit", ev => {
|
||||
const f = ev.target;
|
||||
if (!f || !f.querySelector) { return; }
|
||||
const clave = f.querySelector('input[type="password"]');
|
||||
if (!clave || !clave.value) { return; }
|
||||
const usuario = campoUsuario(clave);
|
||||
browser.runtime.sendMessage({
|
||||
tipo: "se-envio",
|
||||
username: usuario ? usuario.value : "",
|
||||
password: clave.value,
|
||||
});
|
||||
}, true);
|
||||
|
||||
avisarSiHay();
|
||||
// Un formulario que aparece después (una pantalla de acceso que se abre sola).
|
||||
new MutationObserver(avisarSiHay).observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
"use strict";
|
||||
// bóveda — las contraseñas las guarda la suite, no el navegador (SDD-BOVEDA §8), v1.
|
||||
//
|
||||
// Esta extensión **no sabe nada**. Pregunta y muestra; quien decide qué credencial
|
||||
// corresponde a qué página es la función pura de `pacha-boveda-core`, del otro lado
|
||||
// del cable. Reimplementar acá la regla de coincidencia sería fabricar un sustituto
|
||||
// paralelo del original —lo que la regla 10 de tawasuyu prohíbe— y, peor, sería
|
||||
// poner en JS la decisión de a quién se le entrega una contraseña.
|
||||
//
|
||||
// ── LO QUE ESTA EXTENSIÓN NO PUEDE HACER, Y ES A PROPÓSITO ───────────────────
|
||||
// No puede sacar una contraseña de la bóveda. Pide `vault.match`, que contesta
|
||||
// títulos y usuarios; la contraseña sale por `vault.fill`, y ese verbo **pregunta
|
||||
// en el escritorio** antes de contestar. Si esta extensión queda comprometida, o si
|
||||
// una página consigue hablarle, lo que obtiene es la lista de títulos de los sitios
|
||||
// que coinciden con su propia dirección.
|
||||
//
|
||||
// ── LA URL LA PONE EL CHROME, NUNCA LA PÁGINA ────────────────────────────────
|
||||
// Todo lo que se le manda al host lleva `tab.url`, que lo dice el navegador. Si la
|
||||
// dirección viniera del content script, una página podría decir que es el banco y
|
||||
// pedir lo que no es suyo. El content script sólo avisa que **hay** un formulario;
|
||||
// nunca dice de quién.
|
||||
|
||||
const HOST = "puriy_costura";
|
||||
|
||||
let puerto = null;
|
||||
let proximoId = 1;
|
||||
// id de petición → qué hacer con la respuesta.
|
||||
const esperando = new Map();
|
||||
|
||||
function di(s) {
|
||||
try { dump("BOVEDA " + s + "\n"); } catch (e) {}
|
||||
}
|
||||
|
||||
function conectar() {
|
||||
try {
|
||||
puerto = browser.runtime.connectNative(HOST);
|
||||
puerto.onMessage.addListener(alRecibir);
|
||||
puerto.onDisconnect.addListener(p => {
|
||||
di("DESCONECTADO " + (p.error ? p.error.message : "sin error"));
|
||||
puerto = null;
|
||||
// Lo que quedó esperando no va a llegar nunca: se le contesta que no, en
|
||||
// vez de dejar a la página esperando para siempre.
|
||||
for (const [, f] of esperando) { try { f(null); } catch (e) {} }
|
||||
esperando.clear();
|
||||
});
|
||||
di("CONECTADO " + HOST);
|
||||
} catch (e) {
|
||||
di("SIN HOST " + e.message);
|
||||
puerto = null;
|
||||
}
|
||||
}
|
||||
|
||||
function pedir(mensaje) {
|
||||
return new Promise(resolver => {
|
||||
if (!puerto) { conectar(); }
|
||||
if (!puerto) { resolver(null); return; }
|
||||
const id = proximoId++;
|
||||
esperando.set(id, resolver);
|
||||
try {
|
||||
puerto.postMessage(Object.assign({ id }, mensaje));
|
||||
} catch (e) {
|
||||
esperando.delete(id);
|
||||
di("NO PUDE PEDIR " + e.message);
|
||||
resolver(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function alRecibir(r) {
|
||||
if (!r || typeof r.id !== "number") { return; }
|
||||
const f = esperando.get(r.id);
|
||||
if (!f) { return; }
|
||||
esperando.delete(r.id);
|
||||
f(r);
|
||||
}
|
||||
|
||||
// ── La insignia: cuántas credenciales hay para lo que estás mirando ──────────
|
||||
|
||||
async function mirar(tabId, url) {
|
||||
if (!url || !/^https?:/.test(url)) {
|
||||
browser.browserAction.setBadgeText({ tabId, text: "" });
|
||||
return 0;
|
||||
}
|
||||
const r = await pedir({ verb: "vault.match", url });
|
||||
if (!r || r.ok !== true) {
|
||||
browser.browserAction.setBadgeText({ tabId, text: "" });
|
||||
return 0;
|
||||
}
|
||||
if (r.locked === true) {
|
||||
// Cerrada no es lo mismo que vacía, y se dice: si no, el usuario cree que
|
||||
// no tiene guardada la contraseña que sí tiene.
|
||||
browser.browserAction.setBadgeText({ tabId, text: "🔒" });
|
||||
return 0;
|
||||
}
|
||||
const n = (r.items || []).length;
|
||||
browser.browserAction.setBadgeText({ tabId, text: n ? String(n) : "" });
|
||||
return n;
|
||||
}
|
||||
|
||||
browser.tabs.onUpdated.addListener((tabId, cambio, tab) => {
|
||||
if (cambio.status === "complete") { mirar(tabId, tab.url); }
|
||||
});
|
||||
|
||||
// ── Rellenar: el usuario lo pide, el escritorio lo autoriza ──────────────────
|
||||
|
||||
browser.browserAction.onClicked.addListener(async tab => {
|
||||
const r = await pedir({ verb: "vault.match", url: tab.url });
|
||||
if (!r || r.ok !== true || r.locked === true) {
|
||||
di("no hay bóveda abierta");
|
||||
return;
|
||||
}
|
||||
const items = r.items || [];
|
||||
if (items.length === 0) { di("nada para " + tab.url); return; }
|
||||
|
||||
// v1: si hay varias, se usa la primera —que viene de la más específica a la
|
||||
// menos, decidido del otro lado—. Elegir entre varias es una pantalla, y la
|
||||
// pantalla es F2c.
|
||||
const elegida = items[0];
|
||||
const f = await pedir({ verb: "vault.fill", url: tab.url, item: elegida.id });
|
||||
if (!f || f.ok !== true) { di("fill falló"); return; }
|
||||
if (f.denied === true) { di("el usuario dijo que no"); return; }
|
||||
if (!f.password) { di("sin contraseña en la respuesta"); return; }
|
||||
|
||||
browser.tabs.sendMessage(tab.id, {
|
||||
tipo: "rellenar",
|
||||
username: f.username || "",
|
||||
password: f.password,
|
||||
totp: f.totp || null,
|
||||
});
|
||||
});
|
||||
|
||||
// ── Guardar lo que el usuario acaba de tipear ────────────────────────────────
|
||||
|
||||
browser.runtime.onMessage.addListener(async (msg, sender) => {
|
||||
if (!msg || !sender || !sender.tab) { return; }
|
||||
// ⚠ La URL sale de `sender.tab.url` —lo que dice el navegador— y NO del
|
||||
// mensaje. Lo único que la página aporta es lo que el usuario tipeó en ella.
|
||||
const url = sender.tab.url;
|
||||
|
||||
if (msg.tipo === "hay-formulario") {
|
||||
mirar(sender.tab.id, url);
|
||||
return;
|
||||
}
|
||||
if (msg.tipo === "se-envio" && msg.password) {
|
||||
const r = await pedir({
|
||||
verb: "vault.save",
|
||||
url,
|
||||
username: msg.username || "",
|
||||
password: msg.password,
|
||||
title: sender.tab.title || "",
|
||||
});
|
||||
if (r && r.denied === true) { di("no se guardó: el usuario dijo que no"); }
|
||||
else if (r && r.ok === true) { di("guardada para " + url); mirar(sender.tab.id, url); }
|
||||
}
|
||||
});
|
||||
|
||||
conectar();
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"manifest_version": 2,
|
||||
"name": "atuq — la bóveda",
|
||||
"description": "Tus contraseñas las guarda la suite, no el navegador: la bóveda las ofrece en el sitio que corresponde y no entrega ninguna sin que vos digas que sí (SDD-BOVEDA §8).",
|
||||
"version": "1.0",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "boveda@atuq.tawasuyu",
|
||||
"strict_min_version": "115.0"
|
||||
}
|
||||
},
|
||||
"permissions": [
|
||||
"nativeMessaging",
|
||||
"tabs",
|
||||
"activeTab",
|
||||
"<all_urls>"
|
||||
],
|
||||
"background": {
|
||||
"scripts": ["fondo.js"]
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["http://*/*", "https://*/*"],
|
||||
"js": ["campos.js"],
|
||||
"run_at": "document_idle",
|
||||
"all_frames": false
|
||||
}
|
||||
],
|
||||
"browser_action": {
|
||||
"default_title": "La bóveda",
|
||||
"default_icon": "atuq128.png"
|
||||
},
|
||||
"icons": {
|
||||
"128": "atuq128.png"
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
"medios@atuq.tawasuyu",
|
||||
"torrent@atuq.tawasuyu",
|
||||
"foco@atuq.tawasuyu",
|
||||
"ia@atuq.tawasuyu"
|
||||
"ia@atuq.tawasuyu",
|
||||
"boveda@atuq.tawasuyu"
|
||||
]
|
||||
}
|
||||
|
||||
Executable
+143
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
"""¿La bóveda está enchufada ENTERA, o a medias? (SDD-BOVEDA §8, D7)
|
||||
|
||||
python3 scripts/test-atuq-boveda-coherente.py
|
||||
python3 scripts/test-atuq-boveda-coherente.py --negative-control
|
||||
|
||||
── QUÉ MIRA, Y POR QUÉ ESTE GUARDIÁN EXISTE APARTE ───────────────────────────────────────────
|
||||
Una extensión de `atuq` está enchufada en CUATRO lugares distintos, y ninguno da error si falta:
|
||||
|
||||
manifest.json el id que la extensión declara
|
||||
distribution/policies.json la política que la instala
|
||||
native-messaging/*.json el permiso para hablarle al host
|
||||
atuq.cfg las preferencias que la acompañan
|
||||
|
||||
Si falta la política, la extensión no se instala y el usuario ve un navegador sin la función. Si
|
||||
falta el permiso del host, la extensión se instala y **se conecta a nada** —`connectNative` falla
|
||||
en silencio y la insignia no aparece nunca—. Si faltan las preferencias, la bóveda y el gestor de
|
||||
Gecko se pelean por el mismo campo. Ninguno de los tres se ve como un error: se ven como «no anda».
|
||||
|
||||
Éste NO reemplaza al guardián de metal (servidor HTTP, navegador real, login real, el diálogo de
|
||||
consentimiento a la vista). Lo precede: mide lo que se puede medir sin construir nada, en un
|
||||
segundo, y ahorra descubrir en metal un renglón que faltaba en un JSON.
|
||||
|
||||
`--negative-control` saca el permiso del host de una copia en memoria y exige que esto lo detecte.
|
||||
Sin él, un guardián que siempre dice que sí se vería idéntico a uno que funciona.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
ATUQ = os.path.join(ROOT, "recipes/atuq")
|
||||
ID = "boveda@atuq.tawasuyu"
|
||||
|
||||
# Las preferencias de D7: de fábrica manda la bóveda, pero el usuario puede volver atrás.
|
||||
PREFS = ["signon.rememberSignons", "signon.autofillForms"]
|
||||
|
||||
fallas = []
|
||||
|
||||
|
||||
def mal(msg):
|
||||
fallas.append(msg)
|
||||
print(f" ✗ {msg}")
|
||||
|
||||
|
||||
def bien(msg):
|
||||
print(f" ✓ {msg}")
|
||||
|
||||
|
||||
def leer(p):
|
||||
with open(os.path.join(ATUQ, p), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def revisar(permisos_host):
|
||||
# 1. La extensión existe y declara el id que todos los demás nombran.
|
||||
try:
|
||||
m = json.loads(leer("extensions/boveda/manifest.json"))
|
||||
except FileNotFoundError:
|
||||
mal("no existe extensions/boveda/manifest.json")
|
||||
return
|
||||
declarado = m.get("browser_specific_settings", {}).get("gecko", {}).get("id")
|
||||
if declarado != ID:
|
||||
mal(f"el manifest declara {declarado!r} y el resto nombra {ID!r}")
|
||||
else:
|
||||
bien(f"la extensión declara {ID}")
|
||||
|
||||
# 2. Los permisos que necesita para poder hacer su trabajo.
|
||||
for p in ("nativeMessaging", "tabs"):
|
||||
if p not in m.get("permissions", []):
|
||||
mal(f"le falta el permiso {p!r}: sin eso no puede {'hablarle al host' if p == 'nativeMessaging' else 'saber qué página mira el usuario'}")
|
||||
if not m.get("content_scripts"):
|
||||
mal("sin content script no puede rellenar ningún campo")
|
||||
if "background" not in m:
|
||||
mal("sin fondo no hay quién hable con el host")
|
||||
|
||||
# 3. Los archivos que el manifest promete.
|
||||
for rel in [m.get("background", {}).get("scripts", []), *[c.get("js", []) for c in m.get("content_scripts", [])]]:
|
||||
for f in rel:
|
||||
if not os.path.exists(os.path.join(ATUQ, "extensions/boveda", f)):
|
||||
mal(f"el manifest nombra {f} y el archivo no está")
|
||||
|
||||
# 4. La política que la instala.
|
||||
pol = json.loads(leer("distribution/policies.json"))
|
||||
ext = pol.get("policies", {}).get("ExtensionSettings", {})
|
||||
if ID not in ext:
|
||||
mal("no está en ExtensionSettings: el navegador no la va a instalar")
|
||||
elif ext[ID].get("installation_mode") != "normal_installed":
|
||||
mal(f"se instala en modo {ext[ID].get('installation_mode')!r}, que no la deja activada")
|
||||
else:
|
||||
bien("la política la instala")
|
||||
|
||||
# 5. El permiso para hablarle al host. Éste es el que falla en SILENCIO.
|
||||
if ID not in permisos_host:
|
||||
mal("no está en allowed_extensions del host: se instala y no se conecta a nada")
|
||||
else:
|
||||
bien("el host la deja hablar")
|
||||
|
||||
# 6. Las preferencias de D7, y que sean `defaultPref` y no `lockPref`.
|
||||
cfg = leer("atuq.cfg")
|
||||
for p in PREFS:
|
||||
if not re.search(rf'defaultPref\("{re.escape(p)}"', cfg):
|
||||
mal(f"falta defaultPref({p!r}): el gestor de Gecko va a pelear por el mismo campo")
|
||||
if re.search(rf'lockPref\("{re.escape(p)}"', cfg):
|
||||
mal(f"{p} está como lockPref: eso decide POR el usuario, y atuq no hace eso")
|
||||
if not fallas:
|
||||
bien("las preferencias están, y como valores de arranque y no como cárcel")
|
||||
|
||||
# 7. Y lo que la extensión NO tiene que hacer: decidir ella qué credencial va.
|
||||
fondo = leer("extensions/boveda/fondo.js")
|
||||
if "sender.tab.url" not in fondo:
|
||||
mal("el fondo no toma la dirección del chrome: una página podría decir que es el banco")
|
||||
else:
|
||||
bien("la dirección la pone el navegador, no la página")
|
||||
|
||||
|
||||
def main():
|
||||
control = "--negative-control" in sys.argv
|
||||
host = json.loads(leer("native-messaging/puriy_costura.json"))
|
||||
permisos = list(host.get("allowed_extensions", []))
|
||||
if control:
|
||||
print("⚠ control negativo: se le saca el permiso del host")
|
||||
permisos = [x for x in permisos if x != ID]
|
||||
|
||||
print(f"── la bóveda en atuq ──")
|
||||
revisar(permisos)
|
||||
|
||||
if control:
|
||||
if any("allowed_extensions" in f for f in fallas):
|
||||
print("\n✓ el control negativo lo detectó")
|
||||
return 0
|
||||
print("\n✗ el control negativo NO lo detectó: este guardián no mide nada")
|
||||
return 1
|
||||
if fallas:
|
||||
print(f"\n✗ {len(fallas)} problema(s)")
|
||||
return 1
|
||||
print("\n✓ la bóveda está enchufada entera")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user