diff --git a/bash/functions/browser/backup_chrome_bookmarks.md b/bash/functions/browser/backup_chrome_bookmarks.md new file mode 100644 index 00000000..d614be61 --- /dev/null +++ b/bash/functions/browser/backup_chrome_bookmarks.md @@ -0,0 +1,79 @@ +--- +name: backup_chrome_bookmarks +kind: function +lang: bash +domain: browser +version: "1.0.0" +purity: impure +signature: "backup_chrome_bookmarks --user-data-dir [--profile ]... [--backup-dir ] [--dry-run]" +description: "Copia byte a byte los archivos Bookmarks de perfiles Chrome/Chromium a un directorio de backup con timestamp ISO. Descubre automáticamente todos los perfiles con Bookmarks si no se especifica ninguno. Preserva el checksum interno del archivo sin parsear ni reserializar el JSON. No requiere que Chromium esté cerrado." +tags: [navegator, chromium, bookmarks, backup, browser, scraping] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "bash/functions/browser/backup_chrome_bookmarks.sh" +params: + - name: --user-data-dir + desc: "(obligatorio) Ruta raíz del user-data-dir de Chrome/Chromium. Ej: ~/.config/chromium-cdp" + - name: --profile + desc: "Nombre de carpeta de perfil a respaldar (repetible). Si no se pasa ninguno se descubren todos los perfiles que contengan un archivo Bookmarks, excluyendo System Profile." + - name: --backup-dir + desc: "Directorio raíz donde se crearán los backups. Default: ~/.local/share/web_scraping/bookmarks-backups" + - name: --dry-run + desc: "Muestra a stderr qué archivos se copiarían y sus tamaños sin escribir nada en disco. El JSON de salida se emite igualmente." +output: "JSON en stdout: {backup_dir: \"\", ts: \"\", profiles: [{profile: \"\", src: \"\", dst: \"\", bytes: N}, ...]}. Perfiles sin Bookmarks se omiten silenciosamente. Exit 0 en éxito o dry-run. Errores a stderr con exit != 0." +--- + +## Ejemplo + +```bash +# Backup de todos los perfiles del chromium-cdp (descubrimiento automático) +source $HOME/fn_registry/bash/functions/browser/backup_chrome_bookmarks.sh +backup_chrome_bookmarks --user-data-dir "$HOME/.config/chromium-cdp" + +# Previsualizar sin tocar nada +backup_chrome_bookmarks \ + --user-data-dir "$HOME/.config/chromium-cdp" \ + --dry-run + +# Backup de perfiles específicos +backup_chrome_bookmarks \ + --user-data-dir "$HOME/.config/chromium-cdp" \ + --profile Default \ + --profile Personal \ + --profile "Profile 1" + +# Backup a directorio personalizado +backup_chrome_bookmarks \ + --user-data-dir "$HOME/.config/chromium-cdp" \ + --backup-dir "$HOME/vaults/backups/bookmarks" + +# Salida esperada (ejemplo): +# {"backup_dir":"/home/enmanuel/.local/share/web_scraping/bookmarks-backups","ts":"20260605T143022","profiles":[{"profile":"Default","src":"/home/enmanuel/.config/chromium-cdp/Default/Bookmarks","dst":"/home/enmanuel/.local/share/web_scraping/bookmarks-backups/20260605T143022/Default/Bookmarks","bytes":4218}]} +``` + +También ejecutable directamente con `fn run`: + +```bash +cd $HOME/fn_registry +./fn run backup_chrome_bookmarks_bash_browser -- \ + --user-data-dir "$HOME/.config/chromium-cdp" --dry-run +``` + +## Cuando usarla + +Úsala antes de cualquier sesión de scraping o automatización que modifique bookmarks de Chromium, para tener un snapshot recuperable. También útil como paso previo en pipelines que reorganizan o importan marcadores masivamente. Combínala con `rotate_backups_bash_infra` para aplicar política de retención sobre el directorio de backups. + +## Gotchas + +- **Copia verbatim para preservar checksum**: el archivo `Bookmarks` de Chromium incluye un campo `checksum` calculado sobre el contenido. Esta función usa `cp -p` sin tocar el contenido — si parseases y reserializases el JSON (con `jq`, `python3 json.dump`, etc.) el checksum quedaría inválido y Chromium podría resetear o ignorar los marcadores al arrancar. +- **No requiere Chromium cerrado**: a diferencia de `clean_chrome_profile_extensions`, esta función solo lee el archivo `Bookmarks`. Chromium no mantiene un lock exclusivo sobre él — la copia es segura con el navegador abierto. El archivo refleja el estado en disco en ese instante; cambios en vuelo en memoria no estarán en el backup hasta que Chromium los persista. +- **Perfiles sin Bookmarks se omiten silenciosamente**: si un perfil existe pero no tiene el archivo `Bookmarks` (perfil recién creado sin haber abierto el navegador), se salta sin error. Solo aparece en el JSON de salida si fue respaldado. +- **System Profile excluido siempre**: el perfil `System Profile` es un perfil interno de Chromium sin datos de usuario y se excluye del descubrimiento automático. +- **Sin jq ni python3**: la emisión del JSON de salida se construye con printf de bash puro, sin dependencias externas. diff --git a/bash/functions/browser/backup_chrome_bookmarks.sh b/bash/functions/browser/backup_chrome_bookmarks.sh new file mode 100644 index 00000000..c8273c16 --- /dev/null +++ b/bash/functions/browser/backup_chrome_bookmarks.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# backup_chrome_bookmarks — copia byte a byte los archivos Bookmarks de perfiles +# Chrome/Chromium a un directorio de backup con timestamp. Preserva el checksum +# interno del archivo sin parsear ni reserializar el JSON. + +set -euo pipefail + +backup_chrome_bookmarks() { + # ── defaults ────────────────────────────────────────────────────────────── + local _user_data_dir="" + local _profiles=() + local _backup_dir="${HOME}/.local/share/web_scraping/bookmarks-backups" + local _dry_run=0 + + # ── parse args ───────────────────────────────────────────────────────────── + _usage() { + cat >&2 <<'EOF' +Usage: backup_chrome_bookmarks --user-data-dir [--profile ]... + [--backup-dir ] [--dry-run] + + --user-data-dir (obligatorio) Raíz de perfiles de Chrome/Chromium. + Ej: ~/.config/chromium-cdp + --profile Nombre de carpeta de perfil a respaldar (repetible). + Si no se pasa ninguno → respalda TODOS los perfiles con + un archivo Bookmarks (excluye System Profile). + --backup-dir Directorio raíz para backups. + Default: ~/.local/share/web_scraping/bookmarks-backups + --dry-run Muestra qué copiaría sin tocar nada. + +Exit codes: + 0 éxito (o dry-run completado) + 1 error de argumento o validación +EOF + return 1 + } + + while [[ $# -gt 0 ]]; do + case "$1" in + --user-data-dir) _user_data_dir="$2"; shift 2 ;; + --profile) _profiles+=("$2"); shift 2 ;; + --backup-dir) _backup_dir="$2"; shift 2 ;; + --dry-run) _dry_run=1; shift ;; + -h|--help) _usage; return 0 ;; + *) echo "backup_chrome_bookmarks: argumento desconocido: $1" >&2; return 1 ;; + esac + done + + # ── validaciones ────────────────────────────────────────────────────────── + if [[ -z "$_user_data_dir" ]]; then + echo "backup_chrome_bookmarks: --user-data-dir es obligatorio" >&2 + return 1 + fi + + if [[ ! -d "$_user_data_dir" ]]; then + echo "backup_chrome_bookmarks: directorio no encontrado: ${_user_data_dir}" >&2 + return 1 + fi + + # ── descubrir perfiles si no se pasó ninguno ─────────────────────────────── + if [[ ${#_profiles[@]} -eq 0 ]]; then + local _candidate + while IFS= read -r -d '' _candidate; do + local _pname + _pname="$(basename "$_candidate")" + # Excluir System Profile (perfil interno de Chromium sin datos de usuario) + if [[ "$_pname" == "System Profile" ]]; then + continue + fi + if [[ -f "${_candidate}/Bookmarks" ]]; then + _profiles+=("$_pname") + fi + done < <(find "$_user_data_dir" -mindepth 1 -maxdepth 1 -type d -print0 | sort -z) + fi + + if [[ ${#_profiles[@]} -eq 0 ]]; then + echo "backup_chrome_bookmarks: no se encontraron perfiles con archivo Bookmarks en: ${_user_data_dir}" >&2 + return 1 + fi + + # ── timestamp único para este backup ────────────────────────────────────── + local _ts + _ts="$(date +%Y%m%dT%H%M%S)" + + # ── procesar perfiles ───────────────────────────────────────────────────── + # Construir el array de resultados JSON manualmente (sin jq ni python3) + local _results="[" + local _first=1 + local _profile + + for _profile in "${_profiles[@]}"; do + local _src="${_user_data_dir}/${_profile}/Bookmarks" + + # Si el perfil no tiene Bookmarks, se omite sin error + if [[ ! -f "$_src" ]]; then + continue + fi + + local _dst="${_backup_dir}/${_ts}/${_profile}/Bookmarks" + local _bytes + _bytes="$(wc -c < "$_src")" + + if [[ $_dry_run -eq 1 ]]; then + echo "backup_chrome_bookmarks: [dry-run] cp -p \"${_src}\" -> \"${_dst}\" (${_bytes} bytes)" >&2 + else + local _dst_dir + _dst_dir="$(dirname "$_dst")" + mkdir -p "$_dst_dir" + cp -p "$_src" "$_dst" + fi + + # Escapar comillas dobles en el path por si acaso + local _src_esc="${_src//\"/\\\"}" + local _dst_esc="${_dst//\"/\\\"}" + local _profile_esc="${_profile//\"/\\\"}" + + local _entry + _entry="$(printf '{"profile":"%s","src":"%s","dst":"%s","bytes":%s}' \ + "$_profile_esc" "$_src_esc" "$_dst_esc" "$_bytes")" + + if [[ $_first -eq 1 ]]; then + _results+="$_entry" + _first=0 + else + _results+=",${_entry}" + fi + done + + _results+="]" + + # ── emitir resultado JSON ────────────────────────────────────────────────── + local _backup_dir_esc="${_backup_dir//\"/\\\"}" + printf '{"backup_dir":"%s","ts":"%s","profiles":%s}\n' \ + "$_backup_dir_esc" "$_ts" "$_results" +} + +# ── auto-ejecución ──────────────────────────────────────────────────────────── +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + backup_chrome_bookmarks "$@" +fi diff --git a/bash/functions/browser/create_chrome_profile.md b/bash/functions/browser/create_chrome_profile.md new file mode 100644 index 00000000..d3b34e38 --- /dev/null +++ b/bash/functions/browser/create_chrome_profile.md @@ -0,0 +1,93 @@ +--- +name: create_chrome_profile +kind: function +lang: bash +domain: browser +version: "1.0.0" +purity: impure +signature: "create_chrome_profile --user-data-dir --profile --name [--port N] [--chrome-path ] [--no-launch] [--timeout-sec N] [--dry-run]" +description: "Crea un perfil Chrome/Chromium nuevo en un user-data-dir: opcionalmente lanza chromium headless vía systemd-run para que la managed policy instale las extensiones forzadas (uBlock, web_proxy) y luego edita Local State para asignar el nombre legible al perfil. Con --no-launch crea solo la estructura de carpetas y la entrada en Local State sin arrancar Chrome." +tags: [navegator, chromium, profile, browser, cdp, headless, scraping] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "bash/functions/browser/create_chrome_profile.sh" +params: + - name: --user-data-dir + desc: "Raíz del user-data-dir de Chrome/Chromium. Puede no existir; la función lo crea. Obligatorio." + - name: --profile + desc: "Nombre de la carpeta del perfil dentro de user-data-dir, por ejemplo: Default, \"Profile 1\", Automation. Obligatorio." + - name: --name + desc: "Nombre legible visible en el selector de perfil de Chrome, por ejemplo: Work, Aurgi, Bot. Obligatorio." + - name: --port + desc: "Puerto CDP para el lanzamiento headless. Default: 9250. Usar un valor distinto al 9222 global para no colisionar." + - name: --chrome-path + desc: "Ruta absoluta al binario chromium/chrome. Si se omite, auto-detecta: chromium, chromium-browser, google-chrome, brave-browser." + - name: --no-launch + desc: "No lanza chromium. Solo crea la carpeta del perfil y edita Local State con el nombre legible. El perfil no tendrá extensiones instaladas. Útil para tests y CRUD offline." + - name: --timeout-sec + desc: "Segundos máximos esperando a que Preferences aparezca tras el lanzamiento headless. Default: 25." + - name: --dry-run + desc: "Describe las acciones que se ejecutarían sin lanzar ni escribir nada. Emite el JSON de resultado con dry_run:true." +output: "JSON en stdout: {\"profile\":\"\",\"name\":\"\",\"launched\":true|false,\"preferences_created\":true|false}. En dry-run añade \"dry_run\":true. Exit 0 en éxito." +--- + +## Ejemplo + +```bash +source $HOME/fn_registry/bash/functions/browser/create_chrome_profile.sh + +# Modo offline (no lanza Chrome, solo CRUD de Local State — seguro para tests) +create_chrome_profile \ + --user-data-dir /tmp/test_udd \ + --profile "Automation" \ + --name "Aurgi Bot" \ + --no-launch +# Salida: {"profile":"Automation","name":"Aurgi Bot","launched":false,"preferences_created":false} + +# Modo normal: lanza headless para que la policy instale uBlock y web_proxy, +# luego asigna nombre en Local State +create_chrome_profile \ + --user-data-dir "$HOME/.local/share/web_scraping/profiles" \ + --profile "Profile 1" \ + --name "Work" \ + --port 9250 +# Salida: {"profile":"Profile 1","name":"Work","launched":true,"preferences_created":true} + +# Dry-run: describe acciones sin ejecutar nada +create_chrome_profile \ + --user-data-dir "$HOME/.local/share/web_scraping/profiles" \ + --profile "Default" \ + --name "Scraping" \ + --dry-run +``` + +## Cuando usarla + +Úsala para aprovisionar perfiles nuevos en un user-data-dir de automatización antes de lanzar sesiones CDP con `script-navegador` o funciones del grupo `navegator`. En modo normal (sin `--no-launch`) la managed policy instala automáticamente uBlock y la extensión web_proxy en el perfil nuevo; en `--no-launch` sirve para tests unitarios o para crear la entrada de Local State sin depender de Chrome. + +## Gotchas + +- **Lanzar chromium desde Bash tool de Claude da exit-144**: la función usa `systemd-run --user --collect` para aislar el proceso en su propio cgroup, evitando que el harness del agente lo mate. Esto es obligatorio; lanzar con `&` / `setsid` daría exit-144 en el contexto del agente. +- **La managed policy instala las extensiones al arrancar el perfil**: NO pasar `--disable-extensions` — rompería la forcelist. Las extensiones force-listed (`ExtensionInstallForcelist` en `/etc/chromium/policies/managed/extensions.json`) se instalan en el perfil durante el primer arranque; en el headless inicial puede no completar la descarga si no hay red o si el timeout es corto. +- **Dos chromium NO pueden compartir el mismo user-data-dir**: si ya hay un chromium corriendo sobre `--user-data-dir`, la función detecta `SingletonLock` y sale con exit 2 antes de lanzar. Para perfiles de automatización paralela, usa un `--user-data-dir` dedicado por perfil. +- **Local State debe editarse con Chrome muerto**: la función para el unit de systemd y espera la desaparición de `SingletonLock` antes de editar `Local State`. Si se edita mientras Chrome está vivo, Chrome sobreescribe el archivo desde memoria al salir y los cambios de nombre se pierden. +- **`--remote-allow-origins=*` necesita comillas en zsh**: el glob `*` se expande si no va entre comillas. La función pasa el flag correctamente internamente, pero si lo pasas tú en otros scripts acuérdate de las comillas. +- **Perfil diario en `~/.config/chromium-cdp`**: en este equipo el fragmento `/etc/chromium.d/cdp` redirige el user-data-dir global a `~/.config/chromium-cdp`. Para automatización usar siempre un `--user-data-dir` dedicado fuera de `~/.config/`. +- **Timeout corto puede dar `preferences_created: false`**: el perfil headless tarda entre 2-8 segundos en crear `Preferences` según la carga del sistema. Si se aumenta `--timeout-sec` a 45-60 en máquinas lentas se evitan falsos timeouts. + +## Exit codes + +| Código | Significado | +|--------|------------| +| 0 | Éxito | +| 1 | Argumento obligatorio faltante o binario no encontrado | +| 2 | Lock: ya hay un chromium usando el mismo user-data-dir | +| 3 | Timeout esperando a que Preferences se cree | +| 4 | Error editando Local State (JSON inválido tras escritura) | diff --git a/bash/functions/browser/create_chrome_profile.sh b/bash/functions/browser/create_chrome_profile.sh new file mode 100644 index 00000000..14268bf4 --- /dev/null +++ b/bash/functions/browser/create_chrome_profile.sh @@ -0,0 +1,304 @@ +#!/usr/bin/env bash +# create_chrome_profile — crea un perfil Chrome/Chromium nuevo en un user-data-dir, +# opcionalmente lanzando chromium headless para que la managed policy instale las +# extensiones forzadas (uBlock, web_proxy). Edita Local State para asignar el nombre +# legible al perfil. + +set -euo pipefail + +create_chrome_profile() { + # ── defaults ────────────────────────────────────────────────────────────── + local _udd="" + local _profile_dir="" + local _name="" + local _port=9250 + local _chrome_path="" + local _no_launch=0 + local _timeout_sec=25 + local _dry_run=0 + + # ── parse args ───────────────────────────────────────────────────────────── + _usage() { + cat >&2 <<'EOF' +Usage: create_chrome_profile --user-data-dir --profile --name + [--port N] [--chrome-path ] [--no-launch] [--timeout-sec N] [--dry-run] + + --user-data-dir Raíz del user-data-dir de Chrome/Chromium (obligatorio). + --profile Nombre de la carpeta del perfil dentro de user-data-dir, ej: Default, + "Profile 1", Automation (obligatorio). + --name Nombre legible que aparece en el selector de perfil, ej: Work, Aurgi + (obligatorio). + --port Puerto CDP para el lanzamiento headless. Default: 9250. + Usar un puerto distinto al 9222 global para no chocar. + --chrome-path Ruta explícita al binario chromium/chrome. Auto-detecta si se omite. + --no-launch No lanza chromium. Crea la carpeta y edita Local State offline. + El perfil no tendrá extensiones instaladas; útil para tests/CRUD. + --timeout-sec Segundos esperando a que Preferences aparezca tras el lanzamiento. + Default: 25. + --dry-run Describe las acciones sin lanzar ni escribir nada. + +Exit codes: + 0 éxito + 1 error de argumento o validación + 2 lock: ya hay un chromium usando este user-data-dir + 3 timeout esperando a que Preferences se cree + 4 error editando Local State (JSON inválido tras escritura) +EOF + return 1 + } + + while [[ $# -gt 0 ]]; do + case "$1" in + --user-data-dir) _udd="$2"; shift 2 ;; + --profile) _profile_dir="$2"; shift 2 ;; + --name) _name="$2"; shift 2 ;; + --port) _port="$2"; shift 2 ;; + --chrome-path) _chrome_path="$2"; shift 2 ;; + --no-launch) _no_launch=1; shift ;; + --timeout-sec) _timeout_sec="$2"; shift 2 ;; + --dry-run) _dry_run=1; shift ;; + -h|--help) _usage; return 0 ;; + *) echo "create_chrome_profile: argumento desconocido: $1" >&2; return 1 ;; + esac + done + + # ── validaciones obligatorias ────────────────────────────────────────────── + if [[ -z "$_udd" ]]; then + echo "create_chrome_profile: --user-data-dir es obligatorio" >&2 + return 1 + fi + if [[ -z "$_profile_dir" ]]; then + echo "create_chrome_profile: --profile es obligatorio" >&2 + return 1 + fi + if [[ -z "$_name" ]]; then + echo "create_chrome_profile: --name es obligatorio" >&2 + return 1 + fi + + local _profile_path="${_udd}/${_profile_dir}" + local _local_state="${_udd}/Local State" + local _prefs_file="${_profile_path}/Preferences" + + # ── guard: lock por user-data-dir ───────────────────────────────────────── + # Dos procesos chromium no pueden compartir el mismo user-data-dir. + if [[ $_dry_run -eq 0 && $_no_launch -eq 0 ]]; then + local _singleton="${_udd}/SingletonLock" + if [[ -e "$_singleton" ]]; then + echo "create_chrome_profile: ya hay un chromium corriendo con --user-data-dir=${_udd}" >&2 + echo " (encontrado: ${_singleton})" >&2 + echo " Ciérralo o usa un user-data-dir distinto." >&2 + return 2 + fi + fi + + # ── detección del binario chromium ──────────────────────────────────────── + local _bin="" + if [[ -n "$_chrome_path" ]]; then + if [[ ! -x "$_chrome_path" ]]; then + echo "create_chrome_profile: binario no encontrado o no ejecutable: ${_chrome_path}" >&2 + return 1 + fi + _bin="$_chrome_path" + elif [[ $_no_launch -eq 0 ]]; then + for _candidate in chromium chromium-browser google-chrome brave-browser; do + if command -v "$_candidate" &>/dev/null; then + _bin="$_candidate" + break + fi + done + if [[ -z "$_bin" ]]; then + echo "create_chrome_profile: no se encontró binario chromium en PATH" >&2 + echo " Probados: chromium, chromium-browser, google-chrome, brave-browser" >&2 + echo " Usa --chrome-path o --no-launch." >&2 + return 1 + fi + fi + + # ── modo dry-run ────────────────────────────────────────────────────────── + if [[ $_dry_run -eq 1 ]]; then + echo "=== create_chrome_profile DRY-RUN ===" >&2 + echo " user-data-dir : ${_udd}" >&2 + echo " profile : ${_profile_dir}" >&2 + echo " name : ${_name}" >&2 + if [[ $_no_launch -eq 1 ]]; then + echo " modo : --no-launch (sin chromium)" >&2 + echo " acciones : mkdir -p ${_profile_path}" >&2 + echo " editar ${_local_state} → info_cache + profiles_order" >&2 + else + echo " binario : ${_bin}" >&2 + echo " puerto CDP : ${_port}" >&2 + echo " timeout : ${_timeout_sec}s" >&2 + echo " acciones : systemd-run unit=create-prof- chromium headless" >&2 + echo " poll Preferences hasta ${_timeout_sec}s" >&2 + echo " systemctl --user stop unit" >&2 + echo " editar ${_local_state} → info_cache + profiles_order" >&2 + fi + printf '{"profile":"%s","name":"%s","launched":false,"preferences_created":false,"dry_run":true}\n' \ + "$_profile_dir" "$_name" + return 0 + fi + + # ── crear directorio del perfil ─────────────────────────────────────────── + mkdir -p "$_profile_path" + + # ── también asegurar que user-data-dir existe ────────────────────────────── + mkdir -p "$_udd" + + # ── modo --no-launch: solo estructura + Local State ──────────────────────── + local _launched=false + local _prefs_created=false + + if [[ $_no_launch -eq 1 ]]; then + _update_local_state "$_udd" "$_local_state" "$_profile_dir" "$_name" + if [[ -f "$_prefs_file" ]]; then + _prefs_created=true + fi + printf '{"profile":"%s","name":"%s","launched":false,"preferences_created":%s}\n' \ + "$_profile_dir" "$_name" "$_prefs_created" + return 0 + fi + + # ── lanzar chromium headless vía systemd-run ────────────────────────────── + # systemd-run --user aísla el proceso del cgroup del agente (evita exit-144). + # NO se pasa --disable-extensions para que la managed policy instale las + # extensiones force-listed (uBlock, web_proxy). + local _rand + _rand="$(tr -dc 'a-z0-9' /dev/null || echo "$$")" + local _unit="create-prof-${_rand}" + + systemd-run \ + --user \ + --collect \ + --unit="$_unit" \ + --setenv=DISPLAY=:0 \ + --setenv=XAUTHORITY="${HOME}/.Xauthority" \ + "$_bin" \ + "--user-data-dir=${_udd}" \ + "--profile-directory=${_profile_dir}" \ + "--headless=new" \ + "--no-first-run" \ + "--remote-debugging-port=${_port}" \ + "--remote-allow-origins=*" \ + "about:blank" 2>/dev/null || true + + _launched=true + + # ── poll: esperar a que Preferences exista ──────────────────────────────── + local _elapsed=0 + while [[ $_elapsed -lt $_timeout_sec ]]; do + if [[ -f "$_prefs_file" ]]; then + _prefs_created=true + break + fi + sleep 1 + (( _elapsed++ )) || true + done + + # ── detener el unit Y matar TODO el árbol de chromium de este udd ─────────── + # Necesario para poder editar Local State sin que Chrome lo sobreescriba. Ni el + # `systemctl stop` ni un `pkill -f --user-data-dir=` bastan: los procesos hijos + # (zygote/gpu/renderer) no repiten el flag --user-data-dir pero sí referencian la + # ruta del user-data-dir en otros argumentos. Los matamos por PID seleccionando + # los procesos chromium cuyo cmdline contiene la ruta del udd (seguro: no mata + # este propio script porque filtramos por '[c]hromium'). + systemctl --user kill -s SIGKILL "$_unit" 2>/dev/null || true + systemctl --user stop "$_unit" 2>/dev/null || true + local _wait=0 _pids + while :; do + _pids=$(pgrep -af '[c]hromium' 2>/dev/null | grep -F -- "${_udd}" | awk '{print $1}') + [[ -z "$_pids" ]] && break + # shellcheck disable=SC2086 + kill -TERM $_pids 2>/dev/null || true + sleep 0.5 + (( _wait++ )) || true + if [[ $_wait -ge 20 ]]; then + _pids=$(pgrep -af '[c]hromium' 2>/dev/null | grep -F -- "${_udd}" | awk '{print $1}') + # shellcheck disable=SC2086 + [[ -n "$_pids" ]] && kill -9 $_pids 2>/dev/null || true + break + fi + done + rm -f "${_udd}/SingletonLock" 2>/dev/null || true + + if [[ "$_prefs_created" == false ]]; then + echo "create_chrome_profile: timeout (${_timeout_sec}s) esperando a que se cree: ${_prefs_file}" >&2 + echo " El directorio del perfil puede existir pero está vacío." >&2 + printf '{"profile":"%s","name":"%s","launched":true,"preferences_created":false,"error":"timeout"}\n' \ + "$_profile_dir" "$_name" + return 3 + fi + + # ── editar Local State para asignar nombre legible ──────────────────────── + _update_local_state "$_udd" "$_local_state" "$_profile_dir" "$_name" + + printf '{"profile":"%s","name":"%s","launched":true,"preferences_created":true}\n' \ + "$_profile_dir" "$_name" +} + +# ── helper: editar Local State con python3 ──────────────────────────────────── +# Crea/actualiza info_cache. con name + is_using_default_name=false +# y añade profile_dir a profiles_order si no está. +_update_local_state() { + local _udd="$1" + local _local_state="$2" + local _profile_dir="$3" + local _name="$4" + local _today + _today="$(date +%Y%m%d)" + + # Si Local State no existe, crear una estructura mínima + if [[ ! -f "$_local_state" ]]; then + printf '{"profile":{"info_cache":{},"profiles_order":[]}}\n' > "$_local_state" + fi + + # Backup antes de modificar (no sobreescribir el del mismo día) + local _backup="${_local_state}.bak.${_today}" + if [[ ! -f "$_backup" ]]; then + cp "$_local_state" "$_backup" + fi + + # Editar con python3 + if ! python3 - "$_local_state" "$_profile_dir" "$_name" <<'PY'; then +import sys, json + +ls_path = sys.argv[1] +prof_dir = sys.argv[2] +prof_name = sys.argv[3] + +with open(ls_path, "r", encoding="utf-8") as f: + data = json.load(f) + +# Asegurar estructura profile +profile_section = data.setdefault("profile", {}) +info_cache = profile_section.setdefault("info_cache", {}) + +# Crear o actualizar la entrada del perfil en info_cache +entry = info_cache.setdefault(prof_dir, {}) +entry["name"] = prof_name +entry["is_using_default_name"] = False + +# Añadir a profiles_order si no está +order = profile_section.setdefault("profiles_order", []) +if prof_dir not in order: + order.append(prof_dir) + +with open(ls_path, "w", encoding="utf-8") as f: + json.dump(data, f, separators=(",", ":")) +PY + echo "create_chrome_profile: error editando Local State con python3" >&2 + return 4 + fi + + # Validar JSON tras escritura + if ! python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$_local_state" 2>/dev/null; then + echo "create_chrome_profile: JSON inválido tras escribir Local State; restaurando backup" >&2 + cp "$_backup" "$_local_state" + return 4 + fi +} + +# ── auto-ejecución ──────────────────────────────────────────────────────────── +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + create_chrome_profile "$@" +fi diff --git a/bash/functions/browser/delete_chrome_profile.md b/bash/functions/browser/delete_chrome_profile.md new file mode 100644 index 00000000..6bf827ea --- /dev/null +++ b/bash/functions/browser/delete_chrome_profile.md @@ -0,0 +1,93 @@ +--- +name: delete_chrome_profile +kind: function +lang: bash +domain: browser +version: "1.0.0" +purity: impure +signature: "delete_chrome_profile --user-data-dir --profile [--profile ]... [--dry-run]" +description: "Borra por completo uno o varios perfiles Chrome/Chromium: elimina la carpeta del perfil del disco y limpia todas sus referencias en Local State (info_cache, profiles_order, last_active_profiles, last_used, variations_google_groups). Requiere que Chromium esté cerrado. Hace backup automático de Local State antes de editar y valida el JSON resultante restaurando el backup si es inválido." +tags: [navegator, chromium, profile, cleanup, browser, scraping] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "bash/functions/browser/delete_chrome_profile.sh" +params: + - name: --user-data-dir + desc: "Ruta raíz del user-data-dir de Chrome/Chromium (obligatorio). Ej: ~/.config/chromium" + - name: --profile + desc: "Nombre de la carpeta del perfil a borrar (repetible, mínimo uno obligatorio). Ej: 'Default', 'Profile 1'" + - name: --dry-run + desc: "Muestra qué carpetas borraría y qué claves de Local State quitaría sin tocar nada. No activa el guard de chromium cerrado." +output: "JSON en stdout. Modo real: {deleted:[{profile, dir_removed, local_state_cleaned}...], last_used:'', backup:'Local State.bak.YYYYMMDD'}. Modo dry-run: {dry_run:true, would_delete:[{profile, dir_exists, would_remove, local_state_would_clean}...]}. Errores a stderr con exit != 0." +--- + +## Ejemplo + +```bash +# Cerrar Chromium primero (OBLIGATORIO en modo real) +pkill -TERM chromium + +# Borrar un perfil +source $HOME/fn_registry/bash/functions/browser/delete_chrome_profile.sh +delete_chrome_profile \ + --user-data-dir "$HOME/.config/chromium" \ + --profile "Profile 1" +# Salida: {"deleted":[{"profile":"Profile 1","dir_removed":true,"local_state_cleaned":true}],"last_used":"Default","backup":"Local State.bak.20260606"} + +# Borrar varios perfiles a la vez +delete_chrome_profile \ + --user-data-dir "$HOME/.config/chromium" \ + --profile "Profile 1" \ + --profile "Profile 2" + +# Previsualizar sin tocar nada (no requiere Chromium cerrado) +delete_chrome_profile \ + --user-data-dir "$HOME/.config/chromium" \ + --profile "Profile 1" \ + --dry-run +# Salida: {"dry_run":true,"would_delete":[{"profile":"Profile 1","dir_exists":true,"would_remove":true,"local_state_would_clean":true}]} + +# Con un user-data-dir sintético para pruebas +mkdir -p /tmp/test_udd/Default /tmp/test_udd/"Profile 1" +echo '{"profile":{"info_cache":{"Default":{},"Profile 1":{}},"profiles_order":["Default","Profile 1"],"last_active_profiles":["Profile 1"],"last_used":"Profile 1"},"variations_google_groups":{}}' \ + > "/tmp/test_udd/Local State" +delete_chrome_profile --user-data-dir /tmp/test_udd --profile "Profile 1" --dry-run +``` + +También ejecutable directamente con `fn run`: + +```bash +cd $HOME/fn_registry +./fn run delete_chrome_profile_bash_browser -- \ + --user-data-dir "$HOME/.config/chromium" --profile "Profile 1" --dry-run +``` + +## Cuando usarla + +Úsala cuando necesites limpiar completamente un perfil de Chromium: antes de crear un perfil de scraping fresco, para depurar problemas de perfiles corruptos, o para liberar espacio eliminando perfiles de sesión temporales. A diferencia de borrar solo la carpeta, esta función también retira las referencias de `Local State` para que Chromium no muestre el perfil fantasma ni intente acceder a él al arrancar. + +## Gotchas + +- **Chromium DEBE estar cerrado antes de ejecutar en modo real**. Chromium reescribe `Local State` desde memoria al cerrar y desharía todos los cambios. La función comprueba `pgrep -x chromium` y aborta con exit 2 si detecta procesos vivos. En `--dry-run` este check no se activa. +- **Operación destructiva e irreversible**: todos los datos del perfil (cookies, logins guardados, historial, caché, contraseñas) se pierden permanentemente al borrar la carpeta. No hay papelera. +- **Backup automático de Local State**: antes de editar, la función crea `/Local State.bak.YYYYMMDD`. Si ya existe un backup del día no lo sobreescribe. Restaurar manualmente: `cp "Local State.bak.YYYYMMDD" "Local State"`. +- **Validación JSON tras edición**: si el JSON de Local State queda inválido (raro pero posible con perfiles con nombres muy especiales), la función restaura el backup automáticamente y sale con exit != 0. +- **Nombres de perfil con espacios**: los nombres como `"Profile 1"` se pasan entre comillas al script Python. El parsing usa `json.loads` por lo que los espacios no dan problemas, pero deben pasarse correctamente en el shell: `--profile "Profile 1"`. +- **python3 > jq > warning**: usa python3 para editar Local State, jq como fallback. Si ninguno está disponible, las carpetas se borran pero Local State queda sin modificar (Chromium podría mostrar perfiles fantasma al arrancar). +- **last_used reasignado automáticamente**: si el perfil borrado era el `last_used`, la función asigna el primer perfil restante en `info_cache`. Si no queda ningún perfil, `last_used` queda como cadena vacía. +- **No afecta a `--profile Default` si es el único perfil**: lo borrará igualmente — Chromium puede quedar sin ningún perfil configurado y recreará Default al arrancar. + +## Exit codes + +| Código | Significado | +|--------|-------------| +| 0 | Éxito o dry-run completado | +| 1 | Argumento inválido, directorio o Local State no encontrado, JSON inválido tras edición | +| 2 | Chromium está corriendo (solo en modo real) | diff --git a/bash/functions/browser/delete_chrome_profile.sh b/bash/functions/browser/delete_chrome_profile.sh new file mode 100644 index 00000000..a3bc0f0a --- /dev/null +++ b/bash/functions/browser/delete_chrome_profile.sh @@ -0,0 +1,255 @@ +#!/usr/bin/env bash +# delete_chrome_profile — borra por completo uno o varios perfiles Chrome/Chromium: +# elimina la carpeta del perfil y limpia todas las referencias en Local State +# (info_cache, profiles_order, last_active_profiles, last_used, variations_google_groups). + +set -euo pipefail + +delete_chrome_profile() { + # ── defaults ────────────────────────────────────────────────────────────── + local _user_data_dir="" + local _profiles=() + local _dry_run=0 + + # ── parse args ───────────────────────────────────────────────────────────── + _usage() { + cat >&2 <<'EOF' +Usage: delete_chrome_profile --user-data-dir --profile [--profile ]... [--dry-run] + + --user-data-dir Ruta raíz del user-data-dir de Chrome/Chromium (obligatorio). + --profile Nombre de la carpeta del perfil, ej. "Default" o "Profile 1" + (repetible, al menos uno obligatorio). + --dry-run Muestra qué borraría y qué claves de Local State quitaría + sin tocar nada. + +Exit codes: + 0 éxito (o dry-run completado) + 1 error de argumento o validación + 2 chromium está corriendo (solo en modo real) +EOF + return 1 + } + + while [[ $# -gt 0 ]]; do + case "$1" in + --user-data-dir) _user_data_dir="$2"; shift 2 ;; + --profile) _profiles+=("$2"); shift 2 ;; + --dry-run) _dry_run=1; shift ;; + -h|--help) _usage; return 0 ;; + *) echo "delete_chrome_profile: argumento desconocido: $1" >&2; return 1 ;; + esac + done + + # ── validaciones de argumentos ──────────────────────────────────────────── + if [[ -z "$_user_data_dir" ]]; then + echo "delete_chrome_profile: --user-data-dir es obligatorio" >&2 + return 1 + fi + + if [[ ${#_profiles[@]} -eq 0 ]]; then + echo "delete_chrome_profile: se requiere al menos un --profile" >&2 + return 1 + fi + + if [[ ! -d "$_user_data_dir" ]]; then + echo "delete_chrome_profile: user-data-dir no encontrado: ${_user_data_dir}" >&2 + return 1 + fi + + local _local_state="${_user_data_dir}/Local State" + if [[ ! -f "$_local_state" ]]; then + echo "delete_chrome_profile: Local State no encontrado: ${_local_state}" >&2 + return 1 + fi + + # ── guard: ningún chromium debe tener ESTE user-data-dir abierto (excepto en dry-run) ── + # Por-udd, no global: permite operar sobre un user-data-dir de pruebas mientras el chromium + # diario (con otro --user-data-dir) sigue abierto. + if [[ $_dry_run -eq 0 ]]; then + if pgrep -af '[c]hromium' 2>/dev/null | grep -qF -- "--user-data-dir=${_user_data_dir}"; then + echo "delete_chrome_profile: hay un chromium con este user-data-dir abierto — ciérralo antes de borrar perfiles:" >&2 + echo " pkill -TERM chromium" >&2 + echo "(Chromium reescribe Local State desde memoria al cerrar y desharía el borrado)" >&2 + return 2 + fi + fi + + local _today + _today="$(date +%Y%m%d)" + + # ── modo dry-run ────────────────────────────────────────────────────────── + if [[ $_dry_run -eq 1 ]]; then + echo "=== delete_chrome_profile DRY-RUN ===" >&2 + local _p + for _p in "${_profiles[@]}"; do + local _pdir="${_user_data_dir}/${_p}" + if [[ -d "$_pdir" ]]; then + echo " [borraría] rm -rf ${_pdir}" >&2 + else + echo " [no existe] ${_pdir}" >&2 + fi + echo " [Local State] quitaría claves para perfil: '${_p}'" >&2 + echo " profile.info_cache.${_p}" >&2 + echo " profile.profiles_order (entrada '${_p}')" >&2 + echo " profile.last_active_profiles (entrada '${_p}')" >&2 + echo " profile.last_used (si == '${_p}', reasignar)" >&2 + echo " variations_google_groups.${_p} (si existe)" >&2 + done + + # Construir JSON de dry-run inline + local _dry_items="" _dry_first=1 + for _p in "${_profiles[@]}"; do + local _pdir="${_user_data_dir}/${_p}" + local _sep="" _exists="false" + [[ $_dry_first -eq 0 ]] && _sep="," + _dry_first=0 + [[ -d "$_pdir" ]] && _exists="true" + _dry_items+="${_sep}{\"profile\":\"${_p}\",\"dir_exists\":${_exists},\"would_remove\":${_exists},\"local_state_would_clean\":true}" + done + printf '{"dry_run":true,"would_delete":[%s]}\n' "$_dry_items" + return 0 + fi + + # ── backup de Local State (no sobreescribir el del día) ─────────────────── + local _backup="${_local_state}.bak.${_today}" + if [[ ! -f "$_backup" ]]; then + cp "$_local_state" "$_backup" + fi + + # ── borrar carpetas de perfil ────────────────────────────────────────────── + local _deleted_results=() # "profile|dir_removed|ls_cleaned" + local _p + for _p in "${_profiles[@]}"; do + local _pdir="${_user_data_dir}/${_p}" + local _dir_removed=false + if [[ -d "$_pdir" ]]; then + rm -rf "$_pdir" + _dir_removed=true + fi + _deleted_results+=("${_p}|${_dir_removed}|false") + done + + # ── construir lista Python de perfiles a eliminar ───────────────────────── + local _py_profiles_list="" + for _p in "${_profiles[@]}"; do + _py_profiles_list+="\"${_p}\"," + done + _py_profiles_list="[${_py_profiles_list%,}]" + + # ── editar Local State con python3 ──────────────────────────────────────── + local _ls_cleaned=false + if command -v python3 >/dev/null 2>&1; then + python3 - "$_local_state" "$_py_profiles_list" <<'PY' +import sys, json + +ls_path = sys.argv[1] +profiles_to_delete = json.loads(sys.argv[2]) + +with open(ls_path, "r", encoding="utf-8") as f: + data = json.load(f) + +profile_section = data.get("profile", {}) + +# 1. profile.info_cache — eliminar cada perfil +info_cache = profile_section.get("info_cache", {}) +for p in profiles_to_delete: + info_cache.pop(p, None) + +# 2. profile.profiles_order — quitar entradas del perfil +if "profiles_order" in profile_section and isinstance(profile_section["profiles_order"], list): + profile_section["profiles_order"] = [ + x for x in profile_section["profiles_order"] if x not in profiles_to_delete + ] + +# 3. profile.last_active_profiles — quitar entradas del perfil +if "last_active_profiles" in profile_section and isinstance(profile_section["last_active_profiles"], list): + profile_section["last_active_profiles"] = [ + x for x in profile_section["last_active_profiles"] if x not in profiles_to_delete + ] + +# 4. profile.last_used — reasignar si apunta a un perfil borrado +last_used = profile_section.get("last_used", "") +if last_used in profiles_to_delete: + remaining = [k for k in info_cache.keys() if k not in profiles_to_delete] + profile_section["last_used"] = remaining[0] if remaining else "" + +# 5. variations_google_groups — limpiar entradas del perfil (si existe) +vgg = data.get("variations_google_groups", {}) +for p in profiles_to_delete: + vgg.pop(p, None) + +with open(ls_path, "w", encoding="utf-8") as f: + json.dump(data, f, separators=(",", ":")) +PY + _ls_cleaned=true + + # ── fallback con jq ─────────────────────────────────────────────────────── + elif command -v jq >/dev/null 2>&1; then + local _tmp_ls + _tmp_ls="$(mktemp)" + local _jq_expr="." + for _p in "${_profiles[@]}"; do + _jq_expr+=" | del(.profile.info_cache[\"${_p}\"])" + _jq_expr+=" | del(.variations_google_groups[\"${_p}\"])" + _jq_expr+=" | if .profile.profiles_order then .profile.profiles_order -= [\"${_p}\"] else . end" + _jq_expr+=" | if .profile.last_active_profiles then .profile.last_active_profiles -= [\"${_p}\"] else . end" + done + if jq "${_jq_expr}" "$_local_state" > "$_tmp_ls" 2>/dev/null; then + mv "$_tmp_ls" "$_local_state" + _ls_cleaned=true + else + echo "delete_chrome_profile: advertencia — jq falló editando Local State" >&2 + rm -f "$_tmp_ls" + fi + + else + echo "delete_chrome_profile: advertencia — ni python3 ni jq disponibles; carpetas borradas pero Local State no modificado" >&2 + fi + + # ── validar que el JSON resultante sigue siendo parseable ───────────────── + if [[ "$_ls_cleaned" == "true" ]]; then + if command -v python3 >/dev/null 2>&1; then + if ! python3 -c "import sys, json; json.load(open(sys.argv[1]))" "$_local_state" 2>/dev/null; then + echo "delete_chrome_profile: JSON de Local State inválido tras edición — restaurando backup" >&2 + cp "$_backup" "$_local_state" + return 1 + fi + fi + fi + + # ── actualizar _deleted_results con ls_cleaned ──────────────────────────── + local _updated_results=() + for _entry in "${_deleted_results[@]}"; do + local _ep _edr _els + IFS='|' read -r _ep _edr _els <<< "$_entry" + _updated_results+=("${_ep}|${_edr}|${_ls_cleaned}") + done + + # ── leer last_used resultante ────────────────────────────────────────────── + local _new_last_used="" + if command -v python3 >/dev/null 2>&1; then + _new_last_used="$(python3 -c " +import sys, json +data = json.load(open(sys.argv[1])) +print(data.get('profile', {}).get('last_used', '')) +" "$_local_state" 2>/dev/null || echo "")" + fi + + # ── construir JSON de resultado inline ──────────────────────────────────── + local _result_items="" _res_first=1 + for _entry in "${_updated_results[@]+"${_updated_results[@]}"}"; do + local _pn _dr _lc + IFS='|' read -r _pn _dr _lc <<< "$_entry" + local _rsep="" + [[ $_res_first -eq 0 ]] && _rsep="," + _res_first=0 + _result_items+="${_rsep}{\"profile\":\"${_pn}\",\"dir_removed\":${_dr},\"local_state_cleaned\":${_lc}}" + done + printf '{"deleted":[%s],"last_used":"%s","backup":"Local State.bak.%s"}\n' \ + "$_result_items" "$_new_last_used" "$_today" +} + +# ── auto-ejecución ──────────────────────────────────────────────────────────── +if [[ "${BASH_SOURCE[0]:-}" == "${0}" ]]; then + delete_chrome_profile "$@" +fi diff --git a/bash/functions/browser/restore_chrome_bookmarks.md b/bash/functions/browser/restore_chrome_bookmarks.md new file mode 100644 index 00000000..29111e01 --- /dev/null +++ b/bash/functions/browser/restore_chrome_bookmarks.md @@ -0,0 +1,93 @@ +--- +name: restore_chrome_bookmarks +kind: function +lang: bash +domain: browser +version: "1.0.0" +purity: impure +signature: "restore_chrome_bookmarks --backup-dir [--user-data-dir ] [--profile ]... [--dry-run]" +description: "Restaura archivos Bookmarks de Chrome/Chromium desde un directorio de backup generado por backup_chrome_bookmarks hacia los perfiles destino en user-data-dir. Copia byte a byte con cp -p para preservar el checksum MD5 interno del archivo. Nunca parsea ni reserializa el JSON. Requiere que Chromium esté cerrado antes de ejecutar." +tags: [navegator, chromium, bookmarks, restore, browser, scraping, profile] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "bash/functions/browser/restore_chrome_bookmarks.sh" +params: + - name: --backup-dir + desc: "Directorio de backup con timestamp generado por backup_chrome_bookmarks. Debe contener subdirectorios /Bookmarks. OBLIGATORIO." + - name: --user-data-dir + desc: "Ruta raíz del user-data-dir de Chrome/Chromium destino. Default: ~/.config/chromium" + - name: --profile + desc: "Nombre del perfil a restaurar (repetible, ej. Default, Profile 1). Si no se pasa ninguno se restauran TODOS los perfiles presentes en el backup-dir." + - name: --dry-run + desc: "Muestra qué archivos se copiarían y cuáles Bookmarks.bak se borrarían, sin tocar nada en disco." +output: "JSON en stdout: {\"restored\": [{\"profile\": \"Default\", \"dst\": \"\", \"bytes\": N}, ...]}. Exit 0 en éxito o dry-run. Errores a stderr con exit != 0." +--- + +## Ejemplo + +```bash +# PASO 1 — cerrar Chromium (OBLIGATORIO en modo real) +pkill -TERM chromium + +# PASO 2 — restaurar todos los perfiles desde el backup más reciente +source $HOME/fn_registry/bash/functions/browser/restore_chrome_bookmarks.sh +restore_chrome_bookmarks \ + --user-data-dir "$HOME/.config/chromium" \ + --backup-dir "$HOME/backups/chromium_bookmarks/2026-06-04T15:30:00" + +# Restaurar solo un perfil concreto +restore_chrome_bookmarks \ + --backup-dir "$HOME/backups/chromium_bookmarks/2026-06-04T15:30:00" \ + --profile Default + +# Restaurar dos perfiles específicos +restore_chrome_bookmarks \ + --backup-dir "$HOME/backups/chromium_bookmarks/2026-06-04T15:30:00" \ + --profile Default \ + --profile "Profile 1" + +# Previsualizar sin tocar nada (no necesita Chromium cerrado) +restore_chrome_bookmarks \ + --backup-dir "$HOME/backups/chromium_bookmarks/2026-06-04T15:30:00" \ + --dry-run + +# Salida esperada: +# {"restored":[{"profile":"Default","dst":"/home/enmanuel/.config/chromium/Default/Bookmarks","bytes":12453}]} +``` + +También ejecutable directamente con `fn run`: + +```bash +cd $HOME/fn_registry +./fn run restore_chrome_bookmarks_bash_browser -- \ + --backup-dir "$HOME/backups/chromium_bookmarks/2026-06-04T15:30:00" \ + --dry-run +``` + +## Cuando usarla + +Úsala después de una sesión de scraping o automatización que haya alterado los bookmarks, o para recuperar bookmarks tras formatear/recrear un perfil de Chromium. Combínala con `backup_chrome_bookmarks` (que genera el `--backup-dir` con la estructura esperada) para tener un ciclo completo de backup/restore. También útil para propagar bookmarks de un perfil o PC a otro. + +## Gotchas + +- **Chromium DEBE estar cerrado** antes de ejecutar en modo real. Chromium mantiene los bookmarks en memoria y los reescribe al archivo `Bookmarks` al cerrar; si restauras con Chromium abierto, el proceso sobreescribirá tu restauración al cerrarse. La función lo comprueba con `pgrep -x chromium` y aborta con exit 2 si hay procesos vivos. En `--dry-run` este check se omite. +- **Copia verbatim — nunca reserializar el JSON**. El archivo `Bookmarks` contiene un campo `checksum` con el MD5 del propio contenido JSON (calculado por Chromium internamente). Si se parsea y reserializa el JSON (aunque sea equivalente), el checksum queda inválido y Chromium descarta silenciosamente el archivo y regenera uno vacío. Esta función usa `cp -p` para garantizar que los bytes son idénticos al original. +- **En Chromium 148 los bookmarks NO están bajo `super_mac` de Secure Preferences**. No es necesario tocar `Preferences` ni `Secure Preferences` al restaurar bookmarks (a diferencia de extensiones). La función solo opera sobre el archivo `Bookmarks`. +- **`Bookmarks.bak` residual se borra**. Chromium crea `Bookmarks.bak` como copia de seguridad interna. Si existe antes de la restauración, esta función lo borra para que Chromium no lo use como fallback en lugar del archivo recién restaurado. +- **El directorio destino del perfil se crea si no existe**. Si el perfil aún no tiene directorio en `user-data-dir`, se crea con `mkdir -p`. Chromium lo inicializará correctamente la primera vez que arranque con ese perfil. +- **Opera por perfil**. Si no pasas `--profile`, restaura todos los perfiles presentes en el backup. Pasa `--profile` explícito para restaurar selectivamente y evitar sobreescribir perfiles sin querer. + +## Exit codes + +| Código | Significado | +|--------|------------| +| 0 | Éxito o dry-run completado | +| 1 | Argumento inválido, backup-dir/user-data-dir no encontrado, o perfil no presente en backup | +| 2 | Chromium está corriendo (solo en modo real) | diff --git a/bash/functions/browser/restore_chrome_bookmarks.sh b/bash/functions/browser/restore_chrome_bookmarks.sh new file mode 100644 index 00000000..fc11ef2f --- /dev/null +++ b/bash/functions/browser/restore_chrome_bookmarks.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# restore_chrome_bookmarks — restaura archivos Bookmarks de un backup generado por +# backup_chrome_bookmarks hacia los perfiles destino en user-data-dir. +# Copia byte a byte con cp -p (nunca parsea ni reserializa el JSON). + +set -euo pipefail + +restore_chrome_bookmarks() { + # ── defaults ────────────────────────────────────────────────────────────── + local _user_data_dir="${HOME}/.config/chromium" + local _backup_dir="" + local _profiles=() + local _dry_run=0 + + # ── parse args ──────────────────────────────────────────────────────────── + _usage() { + cat >&2 <<'EOF' +Usage: restore_chrome_bookmarks --backup-dir + [--user-data-dir ] [--profile ]... [--dry-run] + + --user-data-dir Raíz de perfiles destino. Default: ~/.config/chromium + --backup-dir Directorio de backup con timestamp generado por + backup_chrome_bookmarks. Debe contener subdirectorios + /Bookmarks. OBLIGATORIO. + --profile Perfil a restaurar (repetible). Si no se pasa ninguno + se restauran TODOS los perfiles presentes en backup-dir. + --dry-run Muestra qué se copiaría sin tocar nada. + +Exit codes: + 0 éxito (o dry-run completado) + 1 error de argumento o validación + 2 chromium está corriendo (solo en modo real) +EOF + return 1 + } + + while [[ $# -gt 0 ]]; do + case "$1" in + --user-data-dir) _user_data_dir="$2"; shift 2 ;; + --backup-dir) _backup_dir="$2"; shift 2 ;; + --profile) _profiles+=("$2"); shift 2 ;; + --dry-run) _dry_run=1; shift ;; + -h|--help) _usage; return 0 ;; + *) echo "restore_chrome_bookmarks: argumento desconocido: $1" >&2; return 1 ;; + esac + done + + # ── validaciones ────────────────────────────────────────────────────────── + if [[ -z "$_backup_dir" ]]; then + echo "restore_chrome_bookmarks: --backup-dir es obligatorio" >&2 + return 1 + fi + + if [[ ! -d "$_backup_dir" ]]; then + echo "restore_chrome_bookmarks: backup-dir no encontrado: ${_backup_dir}" >&2 + return 1 + fi + + if [[ ! -d "$_user_data_dir" ]]; then + echo "restore_chrome_bookmarks: user-data-dir no encontrado: ${_user_data_dir}" >&2 + return 1 + fi + + # ── guard: ningún chromium debe tener ESTE user-data-dir abierto (excepto en dry-run) ── + # Por-udd, no global: permite operar sobre un user-data-dir de pruebas mientras el chromium + # diario (con otro --user-data-dir) sigue abierto. + if [[ $_dry_run -eq 0 ]]; then + if pgrep -af '[c]hromium' 2>/dev/null | grep -qF -- "--user-data-dir=${_user_data_dir}"; then + echo "restore_chrome_bookmarks: hay un chromium con este user-data-dir abierto — ciérralo antes de restaurar:" >&2 + echo " pkill -TERM chromium" >&2 + echo "(Chromium reescribe Bookmarks desde memoria al cerrar y desharía la restauración)" >&2 + return 2 + fi + fi + + # ── determinar perfiles a restaurar ─────────────────────────────────────── + local _target_profiles=() + + if [[ ${#_profiles[@]} -gt 0 ]]; then + # Perfiles explícitos: verificar que existen en el backup + local _p + for _p in "${_profiles[@]}"; do + if [[ ! -f "${_backup_dir}/${_p}/Bookmarks" ]]; then + echo "restore_chrome_bookmarks: backup no contiene perfil '${_p}': ${_backup_dir}/${_p}/Bookmarks" >&2 + return 1 + fi + _target_profiles+=("$_p") + done + else + # Autodescubrir todos los perfiles en el backup + local _profile_path + while IFS= read -r -d '' _profile_path; do + local _pname + _pname="$(basename "$(dirname "$_profile_path")")" + _target_profiles+=("$_pname") + done < <(find "$_backup_dir" -mindepth 2 -maxdepth 2 -name "Bookmarks" -print0 | sort -z) + + if [[ ${#_target_profiles[@]} -eq 0 ]]; then + echo "restore_chrome_bookmarks: no se encontraron archivos Bookmarks en: ${_backup_dir}" >&2 + return 1 + fi + fi + + # ── restaurar cada perfil ───────────────────────────────────────────────── + local _restored_json="" + local _first=1 + + local _prof + for _prof in "${_target_profiles[@]}"; do + local _src="${_backup_dir}/${_prof}/Bookmarks" + local _dst_dir="${_user_data_dir}/${_prof}" + local _dst="${_dst_dir}/Bookmarks" + local _dst_bak="${_dst_dir}/Bookmarks.bak" + + # Tamaño del archivo fuente para el JSON de salida + local _bytes=0 + if [[ -f "$_src" ]]; then + _bytes="$(wc -c < "$_src")" + # Eliminar espacios que wc puede añadir en algunas plataformas + _bytes="${_bytes// /}" + fi + + if [[ $_dry_run -eq 1 ]]; then + echo "=== restore_chrome_bookmarks DRY-RUN ===" >&2 + echo " Perfil : ${_prof}" >&2 + echo " src : ${_src}" >&2 + echo " dst : ${_dst}" >&2 + echo " bytes : ${_bytes}" >&2 + if [[ -f "$_dst_bak" ]]; then + echo " .bak : borraría ${_dst_bak}" >&2 + fi + else + # Crear directorio destino si no existe + mkdir -p "$_dst_dir" + + # Copiar byte a byte preservando timestamps (NUNCA reserializar) + cp -p "$_src" "$_dst" + + # Borrar Bookmarks.bak residual si existe + if [[ -f "$_dst_bak" ]]; then + rm -f "$_dst_bak" + fi + fi + + # Construir fragmento JSON para este perfil + local _entry + _entry="$(printf '{"profile":"%s","dst":"%s","bytes":%s}' \ + "$_prof" "$_dst" "$_bytes")" + + if [[ $_first -eq 1 ]]; then + _restored_json="${_entry}" + _first=0 + else + _restored_json+=",$_entry" + fi + done + + # ── emitir resultado JSON ───────────────────────────────────────────────── + printf '{"restored":[%s]}\n' "$_restored_json" +} + +# ── auto-ejecución ──────────────────────────────────────────────────────────── +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + restore_chrome_bookmarks "$@" +fi diff --git a/bash/functions/pipelines/reset_chrome_profiles.md b/bash/functions/pipelines/reset_chrome_profiles.md new file mode 100644 index 00000000..d8c83b2d --- /dev/null +++ b/bash/functions/pipelines/reset_chrome_profiles.md @@ -0,0 +1,67 @@ +--- +name: reset_chrome_profiles +kind: pipeline +lang: bash +domain: pipelines +version: "1.0.0" +purity: impure +signature: "reset_chrome_profiles --user-data-dir [--profile \"=\"]... [--backup-dir ] [--base-port 9250] [--keep ]... [--dry-run] [--yes]" +description: "Pipeline de reset destructivo de perfiles de Chromium: hace backup de los bookmarks de todos los perfiles, cierra el chromium que use ese user-data-dir, borra los perfiles (carpeta + Local State), los recrea (la managed policy reinstala la whitelist de extensiones uBlock + web_proxy), restaura los bookmarks y verifica que cada perfil quedó solo con la whitelist. DESTRUCTIVO: se pierden cookies, logins, historial y contraseñas; solo los bookmarks se preservan. Requiere --yes en modo real." +tags: [launcher, navegator, chromium, pipeline, profile, reset] +uses_functions: + - backup_chrome_bookmarks_bash_browser + - delete_chrome_profile_bash_browser + - create_chrome_profile_bash_browser + - restore_chrome_bookmarks_bash_browser +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: + - name: "--user-data-dir " + desc: "Raíz del user-data-dir de Chromium cuyos perfiles se resetean (ej. ~/.config/chromium-cdp)." + - name: "--profile " + desc: "Perfil a resetear, formato carpeta=nombre-legible (repetible). Default los 4 reales: Default=Work, Personal=Personal, 'Profile 1'=Aurgi, Automation=Automation." + - name: "--backup-dir " + desc: "Directorio donde se guardan los backups de bookmarks. Default ~/.local/share/web_scraping/bookmarks-backups." + - name: "--base-port " + desc: "Puerto CDP base para recrear perfiles (cada perfil usa base+i). Default 9250." + - name: "--keep " + desc: "ID de extensión esperada tras el reset (repetible). Default uBlock Origin Lite + web_proxy toggle. Solo se usa en la verificación final." + - name: "--dry-run" + desc: "Previsualiza los 6 pasos sin tocar el sistema." + - name: "--yes" + desc: "Confirma la operación destructiva (obligatorio en modo real)." +output: "Ejecuta backup → cerrar chromium → delete → create → restore → verify. Emite el progreso de cada paso y un resumen. Sale 0 si todo OK y cada perfil quedó solo con la whitelist; != 0 si falla algún paso o la verificación detecta extensiones fuera de la whitelist." +tested: false +tests: [] +test_file_path: "" +file_path: "bash/functions/pipelines/reset_chrome_profiles.sh" +--- + +## Ejemplo + +```bash +# Previsualizar el reset de los 4 perfiles del chromium diario (no toca nada) +fn run reset_chrome_profiles --user-data-dir "$HOME/.config/chromium-cdp" --dry-run + +# Reset real (destructivo): backup bookmarks, borrar+recrear los 4 perfiles, restaurar bookmarks +fn run reset_chrome_profiles --user-data-dir "$HOME/.config/chromium-cdp" --yes + +# Reset de un solo perfil con nombre legible +fn run reset_chrome_profiles --user-data-dir "$HOME/.config/chromium-cdp" \ + --profile "Automation=Automation" --yes +``` + +## Cuando usarla + +Cuando quieras dejar los perfiles de un Chromium **limpios desde cero** conservando solo la whitelist de extensiones (uBlock + la de captura del web_proxy) y preservando los bookmarks, pero descartando todo el resto del estado (cookies, logins, historial). Útil para volver a un estado conocido de scraping/captura o para limpiar perfiles contaminados. La managed policy de `/etc` ya fuerza la whitelist, así que los perfiles recreados nacen correctos. + +## Gotchas + +- **DESTRUCTIVO**: cookies, logins, historial y contraseñas de los perfiles se pierden de forma irreversible. Solo los bookmarks se preservan (backup + restore byte a byte). Por eso requiere `--yes` en modo real. +- **Cierra el chromium del user-data-dir indicado** (pkill por `--user-data-dir`), no cualquier chromium. Si tienes otro chromium con otro user-data-dir, no se toca. +- **Depende de la managed policy**: los perfiles recreados solo tendrán uBlock + web_proxy si la policy de `/etc/chromium/policies/managed/extensions.json` las fuerza (ver `apply_chromium_extension_policy_bash_browser`). Si la policy no está, los perfiles nacen sin extensiones. +- La verificación final comprueba las carpetas en `/Extensions/`; para una auditoría detallada (nombre, versión, enabled, fromPolicy) usar `list_chrome_profile_extensions_go_browser`. +- Lanzar chromium desde el Bash tool da exit-144; `create_chrome_profile` usa `systemd-run --user` internamente para evitarlo. diff --git a/bash/functions/pipelines/reset_chrome_profiles.sh b/bash/functions/pipelines/reset_chrome_profiles.sh new file mode 100644 index 00000000..0b9e7c51 --- /dev/null +++ b/bash/functions/pipelines/reset_chrome_profiles.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +# reset_chrome_profiles — Pipeline de reset destructivo de perfiles de Chromium. +# +# Compone funciones del registry para: hacer backup de los bookmarks de todos los perfiles, +# cerrar chromium, borrar los perfiles (carpeta + entradas en Local State), recrearlos +# (la managed policy reinstala la whitelist de extensiones: uBlock + web_proxy), restaurar +# los bookmarks y verificar que cada perfil quedó solo con la whitelist. +# +# DESTRUCTIVO: borra cookies, logins, historial y contraseñas de los perfiles. Solo los +# bookmarks se preservan (backup + restore). Requiere --yes en modo real (o --dry-run). +# +# Uso: +# reset_chrome_profiles --user-data-dir +# [--profile "="]... [--backup-dir ] [--base-port 9250] +# [--keep ]... [--dry-run] [--yes] +# +# Defaults de --profile (los 4 perfiles reales): "Default=Work" "Personal=Personal" +# "Profile 1=Aurgi" "Automation=Automation". +# Default de --keep (whitelist esperada tras el reset): uBlock Origin Lite + web_proxy toggle. + +reset_chrome_profiles() { + local _udd="" _backup_dir="${HOME}/.local/share/web_scraping/bookmarks-backups" + local _base_port=9250 _dry_run=0 _yes=0 + local -a _profiles=() + local -a _keep=() + + while [[ $# -gt 0 ]]; do + case "$1" in + --user-data-dir) _udd="$2"; shift 2 ;; + --profile) _profiles+=("$2"); shift 2 ;; + --backup-dir) _backup_dir="$2"; shift 2 ;; + --base-port) _base_port="$2"; shift 2 ;; + --keep) _keep+=("$2"); shift 2 ;; + --dry-run) _dry_run=1; shift ;; + --yes) _yes=1; shift ;; + -h|--help) + grep '^#' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; return 0 ;; + *) echo "reset_chrome_profiles: argumento desconocido: $1" >&2; return 1 ;; + esac + done + + if [[ -z "$_udd" ]]; then + echo "reset_chrome_profiles: --user-data-dir es obligatorio" >&2; return 1 + fi + if [[ ${#_profiles[@]} -eq 0 ]]; then + _profiles=("Default=Work" "Personal=Personal" "Profile 1=Aurgi" "Automation=Automation") + fi + if [[ ${#_keep[@]} -eq 0 ]]; then + _keep=("ddkjiahejlhfcafbddmgiahcphecmpfh" "nanldmckabfghgdebblpfbdbhphhbnde") + fi + + # Localizar las funciones del registry que componemos. + local _dir _root _browser + _dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + _root="$(cd "$_dir/../../.." && pwd)" + _browser="$_root/bash/functions/browser" + local _f + for _f in backup_chrome_bookmarks restore_chrome_bookmarks delete_chrome_profile create_chrome_profile; do + if [[ ! -f "$_browser/$_f.sh" ]]; then + echo "reset_chrome_profiles: falta función $_f en $_browser" >&2; return 1 + fi + # shellcheck disable=SC1090 + source "$_browser/$_f.sh" + done + + echo "=== reset_chrome_profiles ===" + echo " user-data-dir : $_udd" + echo " perfiles : ${_profiles[*]}" + echo " whitelist ext : ${_keep[*]}" + echo " backup-dir : $_backup_dir" + echo " modo : $([[ $_dry_run -eq 1 ]] && echo DRY-RUN || echo REAL)" + echo "" + + # Confirmación obligatoria en modo real. + if [[ $_dry_run -eq 0 && $_yes -eq 0 ]]; then + echo "reset_chrome_profiles: operación DESTRUCTIVA (se pierden cookies/logins/historial)." >&2 + echo " Repite con --yes para confirmar, o usa --dry-run para previsualizar." >&2 + return 3 + fi + + # ── [1/6] Backup de bookmarks (solo lee; chromium puede estar abierto) ────── + echo "[1/6] Backup de bookmarks..." + local _bk_json _ts_dir + if [[ $_dry_run -eq 1 ]]; then + backup_chrome_bookmarks --user-data-dir "$_udd" --backup-dir "$_backup_dir" --dry-run + _ts_dir="" + else + _bk_json="$(backup_chrome_bookmarks --user-data-dir "$_udd" --backup-dir "$_backup_dir")" || { + echo "reset_chrome_profiles: backup falló" >&2; return 1; } + echo "$_bk_json" + _ts_dir="$(printf '%s' "$_bk_json" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["backup_dir"]+"/"+d["ts"])')" + echo " backup en: $_ts_dir" + fi + echo "" + + # ── [2/6] Cerrar chromium que tenga ESTE user-data-dir abierto ───────────── + echo "[2/6] Cerrando chromium con --user-data-dir=$_udd ..." + if [[ $_dry_run -eq 1 ]]; then + echo " (dry-run: no se cierra nada)" + else + if pgrep -af '[c]hromium' 2>/dev/null | grep -qF -- "--user-data-dir=${_udd}"; then + pkill -TERM -f -- "--user-data-dir=${_udd}" 2>/dev/null || true + local _i=0 + while pgrep -af '[c]hromium' 2>/dev/null | grep -qF -- "--user-data-dir=${_udd}"; do + _i=$((_i+1)); [[ $_i -ge 20 ]] && { pkill -9 -f -- "--user-data-dir=${_udd}" 2>/dev/null || true; break; } + sleep 0.5 + done + echo " chromium cerrado." + else + echo " (no había chromium con ese user-data-dir)" + fi + fi + echo "" + + # ── [3/6] Borrar perfiles (carpeta + Local State) ────────────────────────── + echo "[3/6] Borrando perfiles..." + local _del_args=() _pair _pdir + for _pair in "${_profiles[@]}"; do + _pdir="${_pair%%=*}" + _del_args+=(--profile "$_pdir") + done + if [[ $_dry_run -eq 1 ]]; then + delete_chrome_profile --user-data-dir "$_udd" "${_del_args[@]}" --dry-run + else + delete_chrome_profile --user-data-dir "$_udd" "${_del_args[@]}" || { + echo "reset_chrome_profiles: delete falló" >&2; return 1; } + fi + echo "" + + # ── [4/6] Recrear perfiles (la policy reinstala la whitelist al arrancar) ─── + echo "[4/6] Recreando perfiles..." + local _idx=0 _name _port + for _pair in "${_profiles[@]}"; do + _pdir="${_pair%%=*}"; _name="${_pair#*=}"; _port=$((_base_port + _idx)) + if [[ $_dry_run -eq 1 ]]; then + create_chrome_profile --user-data-dir "$_udd" --profile "$_pdir" --name "$_name" --port "$_port" --dry-run + else + create_chrome_profile --user-data-dir "$_udd" --profile "$_pdir" --name "$_name" --port "$_port" || { + echo "reset_chrome_profiles: create de '$_pdir' falló" >&2; return 1; } + fi + _idx=$((_idx+1)) + done + echo "" + + # ── [5/6] Restaurar bookmarks ────────────────────────────────────────────── + echo "[5/6] Restaurando bookmarks..." + if [[ $_dry_run -eq 1 ]]; then + echo " (dry-run: restauraría desde el backup recién creado)" + else + restore_chrome_bookmarks --user-data-dir "$_udd" --backup-dir "$_ts_dir" || { + echo "reset_chrome_profiles: restore falló (continúo a verify)" >&2; } + fi + echo "" + + # ── [6/6] Verificar extensiones por perfil (carpetas en Extensions/) ─────── + echo "[6/6] Verificando extensiones (esperado: solo la whitelist)..." + if [[ $_dry_run -eq 1 ]]; then + echo " (dry-run: verificaría que cada perfil tiene solo ${_keep[*]})" + echo "" + echo "reset_chrome_profiles: DRY-RUN completado, nada se modificó." + return 0 + fi + local _ok=1 + for _pair in "${_profiles[@]}"; do + _pdir="${_pair%%=*}" + local _extdir="$_udd/$_pdir/Extensions" + local -a _present=() + if [[ -d "$_extdir" ]]; then + local _e + for _e in "$_extdir"/*/; do + _e="$(basename "$_e")" + [[ "$_e" == "Temp" || "$_e" == "*" ]] && continue + _present+=("$_e") + done + fi + # Comprobar que todo lo presente está en la whitelist. + local _extra=() + local _id _found + for _id in "${_present[@]}"; do + _found=0 + local _k + for _k in "${_keep[@]}"; do [[ "$_id" == "$_k" ]] && _found=1; done + [[ $_found -eq 0 ]] && _extra+=("$_id") + done + if [[ ${#_extra[@]} -gt 0 ]]; then + echo " ✗ $_pdir: extensiones fuera de whitelist: ${_extra[*]}" + _ok=0 + else + echo " ✓ $_pdir: ${_present[*]:-}" + fi + done + echo "" + if [[ $_ok -eq 1 ]]; then + echo "reset_chrome_profiles: OK — perfiles recreados, bookmarks restaurados, solo la whitelist presente." + return 0 + else + echo "reset_chrome_profiles: verificación con avisos (revisar arriba)." >&2 + return 1 + fi +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + reset_chrome_profiles "$@" +fi diff --git a/functions/browser/list_chrome_profile_extensions.go b/functions/browser/list_chrome_profile_extensions.go new file mode 100644 index 00000000..bb72c483 --- /dev/null +++ b/functions/browser/list_chrome_profile_extensions.go @@ -0,0 +1,165 @@ +package browser + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" +) + +// ProfileExtension holds metadata about a single Chrome/Chromium extension +// installed in a profile. +type ProfileExtension struct { + ID string + Name string + Version string + Location string // "unpacked" | "internal" | "component" | "external_policy" | "unknown" + Enabled bool + FromPolicy bool +} + +// prefExtensionEntry mirrors the relevant fields of each entry in +// extensions.settings inside a Chromium Preferences file. +type prefExtensionEntry struct { + Manifest *struct { + Name string `json:"name"` + Version string `json:"version"` + } `json:"manifest"` + Location int `json:"location"` + State int `json:"state"` +} + +// locationLabel maps Chromium location integers to human-readable strings. +func locationLabel(loc int) string { + switch loc { + case 1: + return "internal" + case 4: + return "unpacked" + case 5: + return "component" + case 7: + return "external_policy_download" + case 10: + return "external_policy" + default: + return "unknown" + } +} + +// isFromPolicy returns true when the location integer indicates +// the extension was installed via enterprise policy. +func isFromPolicy(loc int) bool { + return loc == 5 || loc == 7 || loc == 10 +} + +// fallbackManifest attempts to read Name and Version from the on-disk +// Extensions///manifest.json file. Both return values may be +// empty strings if the file cannot be read or parsed. +func fallbackManifest(extDir, id string) (name, version string) { + idDir := filepath.Join(extDir, id) + vers, err := os.ReadDir(idDir) + if err != nil { + return "", "" + } + // There may be several version directories; use the first one found. + for _, v := range vers { + if !v.IsDir() { + continue + } + mPath := filepath.Join(idDir, v.Name(), "manifest.json") + data, err := os.ReadFile(mPath) + if err != nil { + continue + } + var m struct { + Name string `json:"name"` + Version string `json:"version"` + } + if json.Unmarshal(data, &m) == nil { + return m.Name, m.Version + } + } + return "", "" +} + +// ListChromeProfileExtensions reads the Preferences file of a Chrome/Chromium +// profile and returns one ProfileExtension per entry found in +// extensions.settings. +// +// userDataDir is the user-data-dir of the browser (e.g. ~/.config/chromium). +// An empty string defaults to ~/.config/chromium. +// +// profileDir is the subdirectory name of the profile inside userDataDir +// (e.g. "Default", "Profile 1"). +// +// The returned slice is sorted by ID (deterministic order). +// Returns an error if the Preferences file is missing or contains invalid JSON. +func ListChromeProfileExtensions(userDataDir, profileDir string) ([]ProfileExtension, error) { + if userDataDir == "" { + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + userDataDir = filepath.Join(home, ".config", "chromium") + } + + prefPath := filepath.Join(userDataDir, profileDir, "Preferences") + data, err := os.ReadFile(prefPath) + if err != nil { + return nil, fmt.Errorf("list_chrome_profile_extensions: cannot read Preferences for profile %q: %w", profileDir, err) + } + + // We only need extensions.settings; unmarshal into a minimal shape. + var prefs struct { + Extensions struct { + Settings map[string]prefExtensionEntry `json:"settings"` + } `json:"extensions"` + } + if err := json.Unmarshal(data, &prefs); err != nil { + return nil, fmt.Errorf("list_chrome_profile_extensions: invalid JSON in Preferences for profile %q: %w", profileDir, err) + } + + extDir := filepath.Join(userDataDir, profileDir, "Extensions") + + var result []ProfileExtension + for id, entry := range prefs.Extensions.Settings { + name := "" + version := "" + + if entry.Manifest != nil { + name = entry.Manifest.Name + version = entry.Manifest.Version + } + + // Fallback: try to read from the on-disk manifest.json. + if name == "" || version == "" { + fbName, fbVer := fallbackManifest(extDir, id) + if name == "" { + name = fbName + } + if version == "" { + version = fbVer + } + } + + // state 1 = enabled, 0 = disabled; absent field defaults to enabled. + enabled := entry.State != 0 + + result = append(result, ProfileExtension{ + ID: id, + Name: name, + Version: version, + Location: locationLabel(entry.Location), + Enabled: enabled, + FromPolicy: isFromPolicy(entry.Location), + }) + } + + sort.Slice(result, func(i, j int) bool { + return result[i].ID < result[j].ID + }) + + return result, nil +} diff --git a/functions/browser/list_chrome_profile_extensions.md b/functions/browser/list_chrome_profile_extensions.md new file mode 100644 index 00000000..34688f49 --- /dev/null +++ b/functions/browser/list_chrome_profile_extensions.md @@ -0,0 +1,75 @@ +--- +name: list_chrome_profile_extensions +kind: function +lang: go +domain: browser +version: "1.0.0" +purity: impure +signature: "func ListChromeProfileExtensions(userDataDir, profileDir string) ([]ProfileExtension, error)" +description: "Lee las extensiones instaladas en un perfil de Chrome/Chromium parseando extensions.settings del archivo Preferences. Devuelve ID, Name, Version, Location (string legible), Enabled y FromPolicy para cada extensión. Si userDataDir es vacío usa ~/.config/chromium. Cuando falta el campo manifest en Preferences intenta leer el manifest.json desde el disco (Extensions///manifest.json)." +tags: [chrome, chromium, browser, profile, extensions, navegator] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["encoding/json", "fmt", "os", "path/filepath", "sort"] +params: + - name: userDataDir + desc: "Ruta al user-data-dir de Chrome/Chromium (ej. ~/.config/chromium, ~/.config/google-chrome). Vacío = ~/.config/chromium." + - name: profileDir + desc: "Nombre del subdirectorio del perfil dentro de userDataDir (ej. 'Default', 'Profile 1'). Debe coincidir con el valor de --profile-directory del proceso Chrome." +output: "Slice de ProfileExtension ordenado por ID (orden determinista). Error si Preferences no existe o contiene JSON inválido. Slice vacío sin error si el perfil no tiene ninguna extensión registrada." +tested: true +tests: + - "dos extensiones con IDs ordenados y campos correctos" + - "extension con state 0 tiene Enabled false" + - "perfil sin Preferences devuelve error" + - "Preferences sin extensions.settings devuelve slice vacío sin error" + - "fallback a Extensions///manifest.json cuando falta manifest en Preferences" + - "location 5 y 10 también son FromPolicy true" + - "Preferences con JSON inválido devuelve error" +test_file_path: "functions/browser/list_chrome_profile_extensions_test.go" +file_path: "functions/browser/list_chrome_profile_extensions.go" +--- + +## Ejemplo + +```go +// Listar extensiones del perfil Default del Chromium del usuario +exts, err := browser.ListChromeProfileExtensions("", "Default") +if err != nil { + log.Fatal(err) +} +for _, e := range exts { + policy := "" + if e.FromPolicy { + policy = " [policy]" + } + enabled := "off" + if e.Enabled { + enabled = "on" + } + fmt.Printf("%s %-40s v%-12s %-24s %s%s\n", + e.ID, e.Name, e.Version, e.Location, enabled, policy) +} +// Output (ejemplo): +// dddbmnkl uBlock Origin Lite v1.0.2 external_policy_download on [policy] +// hklob123 My Dev Extension v0.1.0 unpacked on + +// Con ruta explícita (Google Chrome) +exts, err = browser.ListChromeProfileExtensions("/home/user/.config/google-chrome", "Default") +``` + +## Cuando usarla + +Antes de automatizar un perfil de Chrome/Chromium con CDP para auditar qué extensiones están activas, detectar extensiones instaladas por política (FromPolicy) o verificar que una extensión concreta está habilitada. También útil para depurar comportamientos inesperados del navegador causados por extensiones desconocidas. + +## Gotchas + +- **Preferences puede estar bloqueado mientras Chrome está abierto.** En la práctica Chrome escribe atómicamente el archivo y el bloqueo es brevísimo, pero si el proceso está escribiendo en ese instante `os.ReadFile` puede devolver datos parciales. Usar cuando Chrome no esté activo o tolerar reintento. +- **manifest.name puede ser una clave i18n** (`__MSG_appName__`). En ese caso el `Name` devuelto será esa clave, no el string localizado. Las extensiones empaquetadas en el repositorio de Chrome suelen tener el nombre resuelto directamente en el JSON, pero las extensiones no publicadas pueden usar i18n. +- **Extensions del sistema (location 5 = component) siempre tienen FromPolicy = true** aunque no vengan de una política de empresa; son extensiones internas del propio Chromium (PDF viewer, etc.). +- **Extensiones desinstaladas con estado de caché** pueden aparecer en `extensions.settings` con `state: 0` pero sin directorio en `Extensions/`. Esto es normal; `ListChromeProfileExtensions` las devuelve con `Enabled: false`. +- **Profile Directory ≠ Profile Name.** El parámetro `profileDir` debe ser el nombre del directorio (ej. `"Profile 1"`), que corresponde al `Dir` de `ChromeProfile` devuelto por `list_chrome_profiles_go_browser`. +- En Chrome (Google) el user-data-dir por defecto suele ser `~/.config/google-chrome`; en Chromium `~/.config/chromium`. Pasar ruta explícita si no usas Chromium. diff --git a/functions/browser/list_chrome_profile_extensions_test.go b/functions/browser/list_chrome_profile_extensions_test.go new file mode 100644 index 00000000..3c3d7f60 --- /dev/null +++ b/functions/browser/list_chrome_profile_extensions_test.go @@ -0,0 +1,252 @@ +package browser + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// writePreferences writes a synthetic Preferences JSON file into profileDir. +func writePreferences(t *testing.T, profileDir string, settings map[string]any) { + t.Helper() + prefs := map[string]any{ + "extensions": map[string]any{ + "settings": settings, + }, + } + data, err := json.Marshal(prefs) + if err != nil { + t.Fatalf("json.Marshal Preferences: %v", err) + } + if err := os.WriteFile(filepath.Join(profileDir, "Preferences"), data, 0o600); err != nil { + t.Fatalf("WriteFile Preferences: %v", err) + } +} + +func TestListChromeProfileExtensions(t *testing.T) { + t.Run("dos extensiones con IDs ordenados y campos correctos", func(t *testing.T) { + tmpDir := t.TempDir() + profilePath := filepath.Join(tmpDir, "Default") + os.MkdirAll(profilePath, 0o755) + + settings := map[string]any{ + // location 7 = external_policy_download, state 1 = enabled + "dddbmnkl": map[string]any{ + "manifest": map[string]any{ + "name": "uBlock Origin Lite", + "version": "1.0.2", + }, + "location": 7, + "state": 1, + }, + // location 4 = unpacked, state 1 = enabled + "aaabcdef": map[string]any{ + "manifest": map[string]any{ + "name": "My Dev Extension", + "version": "0.1.0", + }, + "location": 4, + "state": 1, + }, + } + writePreferences(t, profilePath, settings) + + exts, err := ListChromeProfileExtensions(tmpDir, "Default") + if err != nil { + t.Fatalf("error inesperado: %v", err) + } + if len(exts) != 2 { + t.Fatalf("esperaba 2 extensiones, got %d", len(exts)) + } + + // Sorted by ID: "aaabcdef" < "dddbmnkl" + if exts[0].ID != "aaabcdef" { + t.Errorf("exts[0].ID = %q, want %q", exts[0].ID, "aaabcdef") + } + if exts[1].ID != "dddbmnkl" { + t.Errorf("exts[1].ID = %q, want %q", exts[1].ID, "dddbmnkl") + } + + // Check names and versions + if exts[0].Name != "My Dev Extension" { + t.Errorf("exts[0].Name = %q, want %q", exts[0].Name, "My Dev Extension") + } + if exts[1].Name != "uBlock Origin Lite" { + t.Errorf("exts[1].Name = %q, want %q", exts[1].Name, "uBlock Origin Lite") + } + if exts[0].Version != "0.1.0" { + t.Errorf("exts[0].Version = %q, want %q", exts[0].Version, "0.1.0") + } + if exts[1].Version != "1.0.2" { + t.Errorf("exts[1].Version = %q, want %q", exts[1].Version, "1.0.2") + } + + // FromPolicy: location 7 → true; location 4 → false + if exts[0].FromPolicy { + t.Errorf("exts[0] (unpacked): FromPolicy debe ser false") + } + if !exts[1].FromPolicy { + t.Errorf("exts[1] (external_policy_download): FromPolicy debe ser true") + } + + // Location strings + if exts[0].Location != "unpacked" { + t.Errorf("exts[0].Location = %q, want %q", exts[0].Location, "unpacked") + } + if exts[1].Location != "external_policy_download" { + t.Errorf("exts[1].Location = %q, want %q", exts[1].Location, "external_policy_download") + } + + // Both enabled + if !exts[0].Enabled { + t.Error("exts[0]: Enabled debe ser true") + } + if !exts[1].Enabled { + t.Error("exts[1]: Enabled debe ser true") + } + }) + + t.Run("extension con state 0 tiene Enabled false", func(t *testing.T) { + tmpDir := t.TempDir() + profilePath := filepath.Join(tmpDir, "Default") + os.MkdirAll(profilePath, 0o755) + + settings := map[string]any{ + "extdisabled": map[string]any{ + "manifest": map[string]any{ + "name": "Disabled Ext", + "version": "2.0.0", + }, + "location": 1, + "state": 0, + }, + } + writePreferences(t, profilePath, settings) + + exts, err := ListChromeProfileExtensions(tmpDir, "Default") + if err != nil { + t.Fatalf("error inesperado: %v", err) + } + if len(exts) != 1 { + t.Fatalf("esperaba 1 extensión, got %d", len(exts)) + } + if exts[0].Enabled { + t.Error("Enabled debe ser false cuando state=0") + } + }) + + t.Run("perfil sin Preferences devuelve error", func(t *testing.T) { + tmpDir := t.TempDir() + os.MkdirAll(filepath.Join(tmpDir, "Default"), 0o755) + // No Preferences file created. + + _, err := ListChromeProfileExtensions(tmpDir, "Default") + if err == nil { + t.Error("esperaba error al faltar Preferences, got nil") + } + }) + + t.Run("Preferences sin extensions.settings devuelve slice vacío sin error", func(t *testing.T) { + tmpDir := t.TempDir() + profilePath := filepath.Join(tmpDir, "Default") + os.MkdirAll(profilePath, 0o755) + + // Write a Preferences with no extensions key at all. + if err := os.WriteFile(filepath.Join(profilePath, "Preferences"), []byte(`{}`), 0o600); err != nil { + t.Fatal(err) + } + + exts, err := ListChromeProfileExtensions(tmpDir, "Default") + if err != nil { + t.Fatalf("error inesperado: %v", err) + } + if len(exts) != 0 { + t.Errorf("esperaba slice vacío, got %d elementos", len(exts)) + } + }) + + t.Run("fallback a Extensions///manifest.json cuando falta manifest en Preferences", func(t *testing.T) { + tmpDir := t.TempDir() + profilePath := filepath.Join(tmpDir, "Default") + os.MkdirAll(profilePath, 0o755) + + const extID = "fallbackext" + const extVer = "3.1.0" + + // Preferences entry without a manifest field. + settings := map[string]any{ + extID: map[string]any{ + "location": 4, + "state": 1, + // no "manifest" key + }, + } + writePreferences(t, profilePath, settings) + + // Create the on-disk manifest.json. + manifestDir := filepath.Join(profilePath, "Extensions", extID, extVer) + os.MkdirAll(manifestDir, 0o755) + manifestData, _ := json.Marshal(map[string]any{ + "name": "Fallback Extension", + "version": extVer, + }) + os.WriteFile(filepath.Join(manifestDir, "manifest.json"), manifestData, 0o600) + + exts, err := ListChromeProfileExtensions(tmpDir, "Default") + if err != nil { + t.Fatalf("error inesperado: %v", err) + } + if len(exts) != 1 { + t.Fatalf("esperaba 1 extensión, got %d", len(exts)) + } + if exts[0].Name != "Fallback Extension" { + t.Errorf("Name = %q, want %q", exts[0].Name, "Fallback Extension") + } + if exts[0].Version != extVer { + t.Errorf("Version = %q, want %q", exts[0].Version, extVer) + } + }) + + t.Run("location 5 y 10 también son FromPolicy true", func(t *testing.T) { + tmpDir := t.TempDir() + profilePath := filepath.Join(tmpDir, "Default") + os.MkdirAll(profilePath, 0o755) + + settings := map[string]any{ + "comp0000": map[string]any{ + "manifest": map[string]any{"name": "Component Ext", "version": "1.0"}, + "location": 5, + "state": 1, + }, + "poli0000": map[string]any{ + "manifest": map[string]any{"name": "Policy Ext", "version": "1.0"}, + "location": 10, + "state": 1, + }, + } + writePreferences(t, profilePath, settings) + + exts, err := ListChromeProfileExtensions(tmpDir, "Default") + if err != nil { + t.Fatalf("error inesperado: %v", err) + } + for _, ext := range exts { + if !ext.FromPolicy { + t.Errorf("extensión %q (location component/policy) debe tener FromPolicy=true", ext.ID) + } + } + }) + + t.Run("Preferences con JSON inválido devuelve error", func(t *testing.T) { + tmpDir := t.TempDir() + profilePath := filepath.Join(tmpDir, "Default") + os.MkdirAll(profilePath, 0o755) + os.WriteFile(filepath.Join(profilePath, "Preferences"), []byte(`{invalid json`), 0o600) + + _, err := ListChromeProfileExtensions(tmpDir, "Default") + if err == nil { + t.Error("esperaba error con JSON inválido, got nil") + } + }) +}