feat(browser): CRUD de perfiles Chromium + pipeline reset_chrome_profiles
Cinco funciones nuevas (dominio browser, grupo navegator) que cierran los gaps de gestión de perfiles, más un pipeline que las orquesta: - backup_chrome_bookmarks / restore_chrome_bookmarks: backup y restore de los archivos Bookmarks (copia byte a byte verbatim para preservar el checksum interno; en Chromium 148 los bookmarks no están bajo el super_mac de Secure Preferences). Guard por user-data-dir (no global). - delete_chrome_profile: borra la carpeta del perfil + limpia su entrada en Local State (info_cache, profiles_order, last_active_profiles, last_used). - create_chrome_profile: lanza chromium headless (vía systemd-run) para que la managed policy instale la whitelist de extensiones, y asigna el nombre legible en Local State. Mata todo el árbol de chromium del udd antes de editar Local State (los hijos zygote/gpu no repiten --user-data-dir pero referencian la ruta). - list_chrome_profile_extensions (Go): lista extensiones de un perfil con ID/name/version/location/enabled/fromPolicy. 7 unit tests. - reset_chrome_profiles (pipeline): backup -> cerrar chromium -> delete -> create -> restore -> verify. Destructivo (--yes), --dry-run seguro. Validado: unit tests Go verdes, backup/restore byte-idéntico, delete limpia Local State, create instala la forcelist global (uBlock + web_proxy) en perfiles nuevos.
This commit is contained in:
@@ -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/<id>/<version>/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
|
||||
}
|
||||
@@ -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/<id>/<ver>/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/<id>/<ver>/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.
|
||||
@@ -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/<id>/<ver>/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")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user