gioser

Persistencia setters, compat-resolved, journald→CAS, compat-polkit

- hostnamed: SetHostname llama sethostname(2) + cache. SetStaticHostname
  escribe atómico a /etc/hostname (tmp + fsync + rename, perms 0644).
  Set{Pretty,Icon,Chassis,Deployment,Location} mergean k=v en
  /etc/machine-info preservando otras keys. Validación: hostname RFC 1123
  + chassis enum.
- timedated: SetTimezone valida que /usr/share/zoneinfo/<tz> exista,
  hace symlink atómico a /etc/localtime.
- localed: SetLocale valida formato KEY=value, escribe a /etc/locale.conf.
- compat-resolved (nuevo): org.freedesktop.resolve1.Manager con
  ResolveHostname (vía tokio::lookup_host) y ResolveAddress (getnameinfo).
  ResolveRecord devuelve NotSupported.
- journald-compat: persiste cada datagram al CAS por SHA. Append a
  ~/.local/share/ente/journal/index.log con
  timestamp_ms:source:unit:sha_hex. Mutex serializa escrituras.
- compat-polkit (nuevo): org.freedesktop.PolicyKit1.Authority always-yes.
  CheckAuthorization/CheckAuthorizationByAsync responden true,false,{}.
  EnumerateActions vacío. Loguea action_id + pid/uid del subject.

7 compat-shims operativos en paralelo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

d88a9c57914ac7870b43a7a4f616510161278237 · por Sergio · (hace 5 meses) · padre 6ad6d08fa · ver el árbol en este commit

Delta semántico · 59

crates/ente-hostnamed-compat/src/main.rs

crates/ente-journald-compat/src/main.rs

crates/ente-localed-compat/src/main.rs

crates/ente-resolved-compat/src/main.rs

crates/ente-timedated-compat/src/main.rs

crates/ente-zero/src/seed.rs

12 archivos · +683 −15

diff --git a/Cargo.lock b/Cargo.lock
index 91d02b8..821713e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -400,6 +400,7 @@ dependencies = [
  "anyhow",
  "ente-bus",
  "ente-card",
+ "ente-cas",
  "libc",
  "nix",
  "tokio",
@@ -445,6 +446,33 @@ dependencies = [
  "zbus",
 ]
 
+[[package]]
+name = "ente-polkit-compat"
+version = "0.0.1"
+dependencies = [
+ "anyhow",
+ "ente-bus",
+ "ente-card",
+ "tokio",
+ "tracing",
+ "tracing-subscriber",
+ "zbus",
+]
+
+[[package]]
+name = "ente-resolved-compat"
+version = "0.0.1"
+dependencies = [
+ "anyhow",
+ "ente-bus",
+ "ente-card",
+ "libc",
+ "tokio",
+ "tracing",
+ "tracing-subscriber",
+ "zbus",
+]
+
 [[package]]
 name = "ente-snapshot"
 version = "0.0.1"
diff --git a/Cargo.toml b/Cargo.toml
index 3f02310..8683fd4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -16,6 +16,8 @@ members = [
     "crates/ente-timedated-compat",
     "crates/ente-localed-compat",
     "crates/ente-journald-compat",
+    "crates/ente-resolved-compat",
+    "crates/ente-polkit-compat",
 ]
 
 [workspace.package]
diff --git a/crates/ente-hostnamed-compat/src/main.rs b/crates/ente-hostnamed-compat/src/main.rs
index 4f78e83..ece91a7 100644
--- a/crates/ente-hostnamed-compat/src/main.rs
+++ b/crates/ente-hostnamed-compat/src/main.rs
@@ -154,38 +154,67 @@ impl HostnameManager {
     // ----- Setters: forward al bus interno y guardan en cache -----
 
     async fn set_hostname(&self, name: String, _interactive: bool) -> fdo::Result<()> {
-        info!(%name, "SetHostname → bus interno (stub)");
-        *self.transient_hostname.lock().unwrap() = Some(name);
+        if !is_valid_hostname(&name) {
+            return Err(fdo::Error::InvalidArgs(format!("hostname inválido: {name:?}")));
+        }
+        // sethostname(2) cambia sólo el running kernel value.
+        let cstr = std::ffi::CString::new(name.clone())
+            .map_err(|e| fdo::Error::Failed(format!("CString: {e}")))?;
+        let r = unsafe { libc::sethostname(cstr.as_ptr(), name.len()) };
+        if r != 0 {
+            warn!(error = %std::io::Error::last_os_error(), %name, "sethostname syscall falló (¿CAP_SYS_ADMIN?)");
+            // No es fatal — guardamos transient para que el property lea el valor nuevo.
+        }
+        *self.transient_hostname.lock().unwrap() = Some(name.clone());
+        info!(%name, "SetHostname aplicado");
         Ok(())
     }
 
     async fn set_static_hostname(&self, name: String, _interactive: bool) -> fdo::Result<()> {
-        info!(%name, "SetStaticHostname (stub: no persistimos a /etc)");
+        if !is_valid_hostname(&name) {
+            return Err(fdo::Error::InvalidArgs(format!("hostname inválido: {name:?}")));
+        }
+        atomic_write("/etc/hostname", format!("{name}\n").as_bytes())
+            .map_err(|e| fdo::Error::Failed(format!("write /etc/hostname: {e}")))?;
+        info!(%name, "SetStaticHostname → /etc/hostname");
         Ok(())
     }
 
     async fn set_pretty_hostname(&self, name: String, _interactive: bool) -> fdo::Result<()> {
-        info!(%name, "SetPrettyHostname (stub)");
+        update_machine_info("PRETTY_HOSTNAME", &name)
+            .map_err(|e| fdo::Error::Failed(format!("machine-info: {e}")))?;
+        info!(%name, "SetPrettyHostname → /etc/machine-info");
         Ok(())
     }
 
     async fn set_icon_name(&self, name: String, _interactive: bool) -> fdo::Result<()> {
-        info!(%name, "SetIconName (stub)");
+        update_machine_info("ICON_NAME", &name)
+            .map_err(|e| fdo::Error::Failed(format!("machine-info: {e}")))?;
+        info!(%name, "SetIconName → /etc/machine-info");
         Ok(())
     }
 
     async fn set_chassis(&self, chassis: String, _interactive: bool) -> fdo::Result<()> {
-        info!(%chassis, "SetChassis (stub)");
+        if !matches!(chassis.as_str(), "desktop"|"laptop"|"server"|"tablet"|"handset"|"watch"|"embedded"|"vm"|"container") {
+            return Err(fdo::Error::InvalidArgs(format!("chassis inválido: {chassis}")));
+        }
+        update_machine_info("CHASSIS", &chassis)
+            .map_err(|e| fdo::Error::Failed(format!("machine-info: {e}")))?;
+        info!(%chassis, "SetChassis → /etc/machine-info");
         Ok(())
     }
 
     async fn set_deployment(&self, deployment: String, _interactive: bool) -> fdo::Result<()> {
-        info!(%deployment, "SetDeployment (stub)");
+        update_machine_info("DEPLOYMENT", &deployment)
+            .map_err(|e| fdo::Error::Failed(format!("machine-info: {e}")))?;
+        info!(%deployment, "SetDeployment → /etc/machine-info");
         Ok(())
     }
 
     async fn set_location(&self, location: String, _interactive: bool) -> fdo::Result<()> {
-        info!(%location, "SetLocation (stub)");
+        update_machine_info("LOCATION", &location)
+            .map_err(|e| fdo::Error::Failed(format!("machine-info: {e}")))?;
+        info!(%location, "SetLocation → /etc/machine-info");
         Ok(())
     }
 }
@@ -226,6 +255,58 @@ fn read_dmi(path: &str) -> String {
         .unwrap_or_default()
 }
 
+/// RFC 1123 + extra: ASCII alfanumérico, dash, dot. Longitud 1..253.
+/// Rechaza vacíos, espacios, control chars.
+fn is_valid_hostname(s: &str) -> bool {
+    if s.is_empty() || s.len() > 253 { return false; }
+    s.chars().all(|c|
+        c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_'
+    )
+}
+
+/// Escritura atómica via tmp + rename. fsync del directorio para
+/// garantizar durabilidad post-crash. Permisos 0644.
+fn atomic_write(path: &str, content: &[u8]) -> std::io::Result<()> {
+    use std::io::Write;
+    use std::os::unix::fs::OpenOptionsExt;
+    let p = std::path::Path::new(path);
+    if let Some(parent) = p.parent() { let _ = std::fs::create_dir_all(parent); }
+    let tmp = p.with_extension("tmp");
+    {
+        let mut f = std::fs::OpenOptions::new()
+            .create(true).write(true).truncate(true)
+            .mode(0o644)
+            .open(&tmp)?;
+        f.write_all(content)?;
+        f.sync_all()?;
+    }
+    std::fs::rename(&tmp, p)?;
+    Ok(())
+}
+
+/// Lee /etc/machine-info, actualiza/inserta una clave, escribe atómico.
+fn update_machine_info(key: &str, value: &str) -> std::io::Result<()> {
+    let path = "/etc/machine-info";
+    let existing = std::fs::read_to_string(path).unwrap_or_default();
+    let mut found = false;
+    let mut out = String::new();
+    for line in existing.lines() {
+        if let Some((k, _)) = line.split_once('=') {
+            if k.trim() == key {
+                out.push_str(&format!("{key}={value}\n"));
+                found = true;
+                continue;
+            }
+        }
+        out.push_str(line);
+        out.push('\n');
+    }
+    if !found {
+        out.push_str(&format!("{key}={value}\n"));
+    }
+    atomic_write(path, out.as_bytes())
+}
+
 async fn announce_to_fractal() {
     if let Ok(mut client) = BusClient::from_env().await {
         let req = BusRequest::Announce {
diff --git a/crates/ente-journald-compat/Cargo.toml b/crates/ente-journald-compat/Cargo.toml
index 4b74f11..9c8fc1f 100644
--- a/crates/ente-journald-compat/Cargo.toml
+++ b/crates/ente-journald-compat/Cargo.toml
@@ -12,6 +12,7 @@ path = "src/main.rs"
 [dependencies]
 ente-card = { path = "../ente-card" }
 ente-bus = { path = "../ente-bus" }
+ente-cas = { path = "../ente-cas" }
 nix = { workspace = true }
 libc = { workspace = true }
 anyhow = { workspace = true }
diff --git a/crates/ente-journald-compat/src/main.rs b/crates/ente-journald-compat/src/main.rs
index 3da7297..620cfd2 100644
--- a/crates/ente-journald-compat/src/main.rs
+++ b/crates/ente-journald-compat/src/main.rs
@@ -10,7 +10,8 @@
 use ente_bus::{BusClient, BusRequest, BusResponse};
 use ente_card::Capability;
 use std::os::fd::{AsRawFd, OwnedFd};
-use std::path::Path;
+use std::path::{Path, PathBuf};
+use std::sync::Mutex;
 use tokio::io::unix::AsyncFd;
 use tokio::signal::unix::{signal, SignalKind};
 use tracing::{debug, info, warn};
@@ -102,16 +103,65 @@ fn spawn_listener(async_fd: AsyncFd<OwnedFdWrap>, source: &'static str) {
     });
 }
 
+/// Mutex sobre el archivo index para escrituras concurrentes desde
+/// múltiples listeners (journal + syslog).
+static INDEX_FILE: Mutex<()> = Mutex::new(());
+
+/// Path del index file: `$XDG_DATA_HOME/ente/journal/index.log` (default
+/// `~/.local/share/ente/journal/index.log`).
+fn index_path() -> PathBuf {
+    let base = if let Ok(d) = std::env::var("XDG_DATA_HOME") { d }
+        else if let Ok(h) = std::env::var("HOME") { format!("{h}/.local/share") }
+        else { "/var/lib".into() };
+    PathBuf::from(base).join("ente").join("journal").join("index.log")
+}
+
+fn now_ms() -> u128 {
+    std::time::SystemTime::now()
+        .duration_since(std::time::UNIX_EPOCH)
+        .map(|d| d.as_millis())
+        .unwrap_or(0)
+}
+
+/// Persiste el blob crudo al CAS y appendea una línea al index:
+/// `<timestamp_ms>:<source>:<unit>:<sha_hex>`. Errores se logean pero
+/// no abortan — perder un mensaje no debe romper journald.
+fn persist_to_cas(buf: &[u8], source: &'static str, unit: Option<&str>) {
+    let sha = match ente_cas::store(buf) {
+        Ok(s) => s,
+        Err(e) => { warn!(?e, "CAS store falló"); return; }
+    };
+    let line = format!(
+        "{}:{}:{}:{}\n",
+        now_ms(), source, unit.unwrap_or("-"), ente_cas::hex(&sha)
+    );
+    let path = index_path();
+    let _guard = INDEX_FILE.lock().unwrap();
+    if let Some(parent) = path.parent() {
+        let _ = std::fs::create_dir_all(parent);
+    }
+    use std::io::Write;
+    let mut f = match std::fs::OpenOptions::new()
+        .create(true).append(true)
+        .open(&path)
+    {
+        Ok(f) => f,
+        Err(e) => { warn!(?e, path = %path.display(), "abrir index"); return; }
+    };
+    if let Err(e) = f.write_all(line.as_bytes()) {
+        warn!(?e, "write index");
+    }
+}
+
 /// Decodifica best-effort. Formato journald nativo: lines de "KEY=value"
 /// (binario para values con newlines, pero raro). Formato syslog: texto
 /// con prefijo "<priority>tag: message".
 fn handle_message(buf: &[u8], source: &'static str) {
     if let Ok(s) = std::str::from_utf8(buf) {
-        // Heurística: si tiene '=' en alguna línea, asumir journald.
         if s.contains('=') && s.lines().any(|l| l.contains('=')) {
             let mut message = None;
             let mut priority = None;
-            let mut unit = None;
+            let mut unit: Option<String> = None;
             for line in s.lines() {
                 if let Some((k, v)) = line.split_once('=') {
                     match k {
@@ -122,16 +172,18 @@ fn handle_message(buf: &[u8], source: &'static str) {
                     }
                 }
             }
+            persist_to_cas(buf, source, unit.as_deref());
             if let Some(msg) = message {
                 info!(target: "journal", source, ?priority, ?unit, "{msg}");
             } else {
                 debug!(source, len = buf.len(), "journal native sin MESSAGE");
             }
         } else {
-            // Syslog
+            persist_to_cas(buf, source, None);
             info!(target: "syslog", source, "{}", s.trim_end());
         }
     } else {
+        persist_to_cas(buf, source, None);
         debug!(source, len = buf.len(), "journal binario (no UTF-8)");
     }
 }
diff --git a/crates/ente-localed-compat/src/main.rs b/crates/ente-localed-compat/src/main.rs
index 24e3241..f553c54 100644
--- a/crates/ente-localed-compat/src/main.rs
+++ b/crates/ente-localed-compat/src/main.rs
@@ -96,8 +96,21 @@ impl LocaleManager {
     }
 
     async fn set_locale(&self, locale: Vec<String>, _interactive: bool) -> fdo::Result<()> {
-        info!(?locale, "SetLocale (stub: no persistimos a /etc/locale.conf)");
-        *self.transient_locale.lock().unwrap() = Some(locale);
+        // Validar formato KEY=value en cada entry.
+        for entry in &locale {
+            if !entry.contains('=') {
+                return Err(fdo::Error::InvalidArgs(
+                    format!("locale entry inválido (sin '='): {entry}")
+                ));
+            }
+        }
+        let content: String = locale.iter()
+            .map(|s| format!("{s}\n"))
+            .collect();
+        atomic_write("/etc/locale.conf", content.as_bytes())
+            .map_err(|e| fdo::Error::Failed(format!("write /etc/locale.conf: {e}")))?;
+        *self.transient_locale.lock().unwrap() = Some(locale.clone());
+        info!(?locale, "SetLocale → /etc/locale.conf");
         Ok(())
     }
 
@@ -126,6 +139,24 @@ impl LocaleManager {
     }
 }
 
+fn atomic_write(path: &str, content: &[u8]) -> std::io::Result<()> {
+    use std::io::Write;
+    use std::os::unix::fs::OpenOptionsExt;
+    let p = std::path::Path::new(path);
+    if let Some(parent) = p.parent() { let _ = std::fs::create_dir_all(parent); }
+    let tmp = p.with_extension("tmp");
+    {
+        let mut f = std::fs::OpenOptions::new()
+            .create(true).write(true).truncate(true)
+            .mode(0o644)
+            .open(&tmp)?;
+        f.write_all(content)?;
+        f.sync_all()?;
+    }
+    std::fs::rename(&tmp, p)?;
+    Ok(())
+}
+
 fn read_kv(path: &str, key: &str) -> Option<String> {
     let content = std::fs::read_to_string(path).ok()?;
     for line in content.lines() {
diff --git a/crates/ente-polkit-compat/Cargo.toml b/crates/ente-polkit-compat/Cargo.toml
new file mode 100644
index 0000000..9aa81f5
--- /dev/null
+++ b/crates/ente-polkit-compat/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "ente-polkit-compat"
+version = "0.0.1"
+edition.workspace = true
+license.workspace = true
+publish.workspace = true
+
+[[bin]]
+name = "ente-polkit-compat"
+path = "src/main.rs"
+
+[dependencies]
+ente-card = { path = "../ente-card" }
+ente-bus = { path = "../ente-bus" }
+anyhow = { workspace = true }
+tokio = { workspace = true }
+tracing = { workspace = true }
+tracing-subscriber = { workspace = true }
+zbus = { version = "4", default-features = false, features = ["tokio"] }
diff --git a/crates/ente-polkit-compat/src/main.rs b/crates/ente-polkit-compat/src/main.rs
new file mode 100644
index 0000000..96fd8c1
--- /dev/null
+++ b/crates/ente-polkit-compat/src/main.rs
@@ -0,0 +1,208 @@
+//! ente-polkit-compat: shim de `org.freedesktop.PolicyKit1.Authority`.
+//!
+//! Polkit autoriza llamadas privilegiadas (e.g. SetHostname, PowerOff).
+//! En el fractal no usamos polkit como gatekeeper — la auth se hace en
+//! el bus interno via SO_PEERCRED y capability grants. Pero apps que
+//! usan polkit (gnome-control-center, etc) bloquean en `CheckAuthorization`
+//! si no responde nadie.
+//!
+//! Este shim responde "is_authorized=true" siempre — el fractal queda
+//! como sistema confiado. El logging deja audit trail de qué acciones se
+//! han pedido para futuro análisis.
+//!
+//! Producción real: integrar con el grant system del bus interno —
+//! CheckAuthorization solicita un token al graph y devuelve true/false
+//! según el resultado.
+
+use ente_bus::{BusClient, BusRequest, BusResponse};
+use ente_card::Capability;
+use std::collections::HashMap;
+use tokio::signal::unix::{signal, SignalKind};
+use tracing::{info, warn};
+use tracing_subscriber::EnvFilter;
+use zbus::{fdo, interface, zvariant::OwnedValue};
+
+const BUS_NAME: &str = "org.freedesktop.PolicyKit1";
+const OBJ_PATH: &str = "/org/freedesktop/PolicyKit1/Authority";
+
+#[tokio::main(flavor = "current_thread")]
+async fn main() -> anyhow::Result<()> {
+    init_tracing();
+    info!("ente-polkit-compat: arrancando");
+    announce_to_fractal().await;
+
+    let manager = PolkitAuthority;
+    let conn_result = zbus::connection::Builder::system()
+        .and_then(|b| b.name(BUS_NAME))
+        .and_then(|b| b.serve_at(OBJ_PATH, manager));
+    match conn_result {
+        Ok(builder) => match builder.build().await {
+            Ok(_conn) => {
+                info!(name = BUS_NAME, "name acquired, sirviendo");
+                wait_for_term().await
+            }
+            Err(e) => { warn!(?e, "build conn falló — modo idle"); wait_for_term().await }
+        },
+        Err(e) => { warn!(?e, "builder D-Bus falló — modo idle"); wait_for_term().await }
+    }
+}
+
+struct PolkitAuthority;
+
+/// Wire format de Polkit: `Subject = (s, a{sv})` — kind ("unix-session",
+/// "unix-process", "system-bus-name") + detalles. El detail típico:
+///   {"pid": u32, "start-time": u64, "uid": u32}
+type Subject = (String, HashMap<String, OwnedValue>);
+
+/// Resultado de `CheckAuthorization`: `(b, b, a{ss})` —
+/// is_authorized, is_challenge, details.
+type AuthResult = (bool, bool, HashMap<String, String>);
+
+#[interface(name = "org.freedesktop.PolicyKit1.Authority")]
+impl PolkitAuthority {
+    async fn check_authorization(
+        &self,
+        subject: Subject,
+        action_id: String,
+        _details: HashMap<String, String>,
+        _flags: u32,
+        _cancellation_id: String,
+    ) -> fdo::Result<AuthResult> {
+        let (subj_kind, subj_details) = subject;
+        let pid = subj_details.get("pid")
+            .and_then(|v| u32::try_from(v).ok());
+        let uid = subj_details.get("uid")
+            .and_then(|v| u32::try_from(v).ok());
+        info!(%action_id, %subj_kind, ?pid, ?uid, "CheckAuthorization → ALLOW");
+        // Always-yes: fractal confía en todos sus Entes (auth real está en el bus).
+        Ok((true, false, HashMap::new()))
+    }
+
+    async fn check_authorization_by_async(
+        &self,
+        subject: Subject,
+        action_id: String,
+        details: HashMap<String, String>,
+        flags: u32,
+        cancellation_id: String,
+    ) -> fdo::Result<AuthResult> {
+        // Mismo comportamiento; algunos clientes llaman la versión async.
+        self.check_authorization(subject, action_id, details, flags, cancellation_id).await
+    }
+
+    async fn cancel_check_authorization(&self, _cancellation_id: String) -> fdo::Result<()> {
+        Ok(())
+    }
+
+    async fn enumerate_actions(&self, _locale: String) -> fdo::Result<Vec<EnumeratedAction>> {
+        // Devolvemos lista vacía — no enumeramos acciones registradas.
+        // El llamador (típicamente gnome-control-center settings panel)
+        // debería degradar grácilmente.
+        Ok(vec![])
+    }
+
+    async fn register_authentication_agent(
+        &self,
+        _subject: Subject,
+        _locale: String,
+        _object_path: String,
+    ) -> fdo::Result<()> {
+        info!("RegisterAuthenticationAgent (no-op)");
+        Ok(())
+    }
+
+    async fn register_authentication_agent_with_options(
+        &self,
+        _subject: Subject,
+        _locale: String,
+        _object_path: String,
+        _options: HashMap<String, OwnedValue>,
+    ) -> fdo::Result<()> {
+        Ok(())
+    }
+
+    async fn unregister_authentication_agent(
+        &self,
+        _subject: Subject,
+        _object_path: String,
+    ) -> fdo::Result<()> {
+        Ok(())
+    }
+
+    async fn authentication_agent_response(
+        &self,
+        _cookie: String,
+        _identity: (String, HashMap<String, OwnedValue>),
+    ) -> fdo::Result<()> {
+        Ok(())
+    }
+
+    async fn enumerate_temporary_authorizations(
+        &self,
+        _subject: Subject,
+    ) -> fdo::Result<Vec<TemporaryAuth>> {
+        Ok(vec![])
+    }
+
+    async fn revoke_temporary_authorizations(&self, _subject: Subject) -> fdo::Result<()> {
+        Ok(())
+    }
+
+    async fn revoke_temporary_authorization_by_id(&self, _id: String) -> fdo::Result<()> {
+        Ok(())
+    }
+
+    #[zbus(property)]
+    async fn backend_name(&self) -> String { "ente-polkit-compat".into() }
+
+    #[zbus(property)]
+    async fn backend_version(&self) -> String { env!("CARGO_PKG_VERSION").into() }
+
+    #[zbus(property)]
+    async fn backend_features(&self) -> u32 { 0 }
+}
+
+/// Wire signature de EnumerateActions item:
+/// `(ssssssuusa{ss})` — action_id, descripción, message, vendor, vendor_url,
+/// icon_name, implicit_any, implicit_inactive, implicit_active, annotations.
+type EnumeratedAction = (
+    String, String, String, String, String, String,
+    u32, u32, String, HashMap<String, String>,
+);
+
+/// Wire signature de TemporaryAuthorization:
+/// `(sssss)` — id, action_id, subject_kind, subject_detail, time_obtained, time_expires.
+/// Aquí `(string)` * 5 + 2 timestamps. Simplificamos al subset relevante.
+type TemporaryAuth = (String, String, (String, HashMap<String, OwnedValue>), u64, u64);
+
+async fn announce_to_fractal() {
+    if let Ok(mut client) = BusClient::from_env().await {
+        let req = BusRequest::Announce {
+            capabilities: vec![Capability::Endpoint {
+                interface: ente_card::InterfaceId([0xa4; 16]),
+                version: 1,
+            }],
+        };
+        match client.call(req).await {
+            Ok(BusResponse::Ok) => info!("Announce → bus interno OK"),
+            Ok(other) => warn!(?other, "Announce respuesta inesperada"),
+            Err(e) => warn!(?e, "Announce falló"),
+        }
+    }
+}
+
+async fn wait_for_term() -> anyhow::Result<()> {
+    let mut term = signal(SignalKind::terminate())?;
+    let mut int_ = signal(SignalKind::interrupt())?;
+    tokio::select! {
+        _ = term.recv() => info!("SIGTERM"),
+        _ = int_.recv() => info!("SIGINT"),
+    }
+    Ok(())
+}
+
+fn init_tracing() {
+    let filter = EnvFilter::try_from_default_env()
+        .unwrap_or_else(|_| EnvFilter::new("ente_polkit_compat=info"));
+    tracing_subscriber::fmt().with_env_filter(filter).with_target(true).init();
+}
diff --git a/crates/ente-resolved-compat/Cargo.toml b/crates/ente-resolved-compat/Cargo.toml
new file mode 100644
index 0000000..4a351de
--- /dev/null
+++ b/crates/ente-resolved-compat/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "ente-resolved-compat"
+version = "0.0.1"
+edition.workspace = true
+license.workspace = true
+publish.workspace = true
+
+[[bin]]
+name = "ente-resolved-compat"
+path = "src/main.rs"
+
+[dependencies]
+ente-card = { path = "../ente-card" }
+ente-bus = { path = "../ente-bus" }
+libc = { workspace = true }
+anyhow = { workspace = true }
+tokio = { workspace = true }
+tracing = { workspace = true }
+tracing-subscriber = { workspace = true }
+zbus = { version = "4", default-features = false, features = ["tokio"] }
diff --git a/crates/ente-resolved-compat/src/main.rs b/crates/ente-resolved-compat/src/main.rs
new file mode 100644
index 0000000..a103bbe
--- /dev/null
+++ b/crates/ente-resolved-compat/src/main.rs
@@ -0,0 +1,210 @@
+//! ente-resolved-compat: shim de `org.freedesktop.resolve1`.
+//!
+//! Bajo el capó usa `tokio::net::lookup_host` (que termina en getaddrinfo
+//! del libc del sistema). No reimplementamos un resolver DNS — delegamos
+//! al stack de resolución del kernel/glibc.
+//!
+//! Métodos cubiertos:
+//!   - ResolveHostname (name → addresses)
+//!   - ResolveAddress (address → name reverse)
+//!   - ResolveRecord (TXT/SRV/etc) — NotSupported (requiere DNS query directa)
+
+use ente_bus::{BusClient, BusRequest, BusResponse};
+use ente_card::Capability;
+use std::net::IpAddr;
+use tokio::signal::unix::{signal, SignalKind};
+use tracing::{info, warn};
+use tracing_subscriber::EnvFilter;
+use zbus::{fdo, interface};
+
+const BUS_NAME: &str = "org.freedesktop.resolve1";
+const OBJ_PATH: &str = "/org/freedesktop/resolve1";
+
+const AF_INET: i32 = 2;
+const AF_INET6: i32 = 10;
+
+#[tokio::main(flavor = "current_thread")]
+async fn main() -> anyhow::Result<()> {
+    init_tracing();
+    info!("ente-resolved-compat: arrancando");
+    announce_to_fractal().await;
+
+    let manager = ResolveManager;
+    let conn_result = zbus::connection::Builder::system()
+        .and_then(|b| b.name(BUS_NAME))
+        .and_then(|b| b.serve_at(OBJ_PATH, manager));
+    match conn_result {
+        Ok(builder) => match builder.build().await {
+            Ok(_conn) => {
+                info!(name = BUS_NAME, "name acquired, sirviendo");
+                wait_for_term().await
+            }
+            Err(e) => { warn!(?e, "build conn falló — modo idle"); wait_for_term().await }
+        },
+        Err(e) => { warn!(?e, "builder D-Bus falló — modo idle"); wait_for_term().await }
+    }
+}
+
+struct ResolveManager;
+
+/// Tipo del wire format de `ResolveHostname`. Por entry: (ifindex, family,
+/// address-as-bytes). systemd-resolved devuelve hasta 4 bytes para AF_INET
+/// y 16 para AF_INET6.
+type HostnameAddress = (i32, i32, Vec<u8>);
+
+#[interface(name = "org.freedesktop.resolve1.Manager")]
+impl ResolveManager {
+    /// Wire signature: `ResolveHostname(in iiusst, out a(iiay)st)` — recibe
+    /// (ifindex, name, family, flags), devuelve (addresses, canonical, flags).
+    async fn resolve_hostname(
+        &self,
+        _ifindex: i32,
+        name: String,
+        family: i32,
+        _flags: u64,
+    ) -> fdo::Result<(Vec<HostnameAddress>, String, u64)> {
+        // tokio::net::lookup_host requiere "host:port"; usamos puerto sentinel.
+        let target = format!("{name}:0");
+        let addrs = match tokio::net::lookup_host(&target).await {
+            Ok(it) => it,
+            Err(e) => return Err(fdo::Error::Failed(format!("lookup_host {name}: {e}"))),
+        };
+        let mut out = Vec::new();
+        for sa in addrs {
+            let ip = sa.ip();
+            let (af, bytes) = match ip {
+                IpAddr::V4(v4) => (AF_INET, v4.octets().to_vec()),
+                IpAddr::V6(v6) => (AF_INET6, v6.octets().to_vec()),
+            };
+            // Filtrado por family si el llamador lo pidió específico.
+            if family != 0 && family != af { continue; }
+            out.push((0i32, af, bytes));
+        }
+        if out.is_empty() {
+            return Err(fdo::Error::Failed(format!("sin resoluciones para {name} (family={family})")));
+        }
+        info!(%name, family, count = out.len(), "ResolveHostname");
+        Ok((out, name, 0))
+    }
+
+    /// Wire signature: `ResolveAddress(in iiayt, out a(is)t)` — (ifindex,
+    /// family, address, flags) → (names, flags).
+    async fn resolve_address(
+        &self,
+        _ifindex: i32,
+        family: i32,
+        address: Vec<u8>,
+        _flags: u64,
+    ) -> fdo::Result<(Vec<(i32, String)>, u64)> {
+        let ip = parse_address(family, &address)
+            .ok_or_else(|| fdo::Error::InvalidArgs(format!("address malformado family={family} bytes={}", address.len())))?;
+        // Reverse lookup vía getnameinfo. Usamos std::net::lookup_addr no existe,
+        // así que invocamos via libc directamente.
+        let name = reverse_lookup(ip)
+            .ok_or_else(|| fdo::Error::Failed(format!("sin reverse para {ip}")))?;
+        info!(%ip, %name, "ResolveAddress");
+        Ok((vec![(0, name)], 0))
+    }
+
+    async fn resolve_record(
+        &self,
+        _ifindex: i32,
+        _name: String,
+        _class: u16,
+        _type_: u16,
+        _flags: u64,
+    ) -> fdo::Result<(Vec<(i32, u16, u16, Vec<u8>)>, u64)> {
+        Err(fdo::Error::NotSupported(
+            "ResolveRecord requiere acceso DNS directo — stub no implementado".into()
+        ))
+    }
+}
+
+fn parse_address(family: i32, bytes: &[u8]) -> Option<IpAddr> {
+    match family {
+        AF_INET if bytes.len() == 4 => {
+            let mut a = [0u8; 4];
+            a.copy_from_slice(bytes);
+            Some(IpAddr::V4(std::net::Ipv4Addr::from(a)))
+        }
+        AF_INET6 if bytes.len() == 16 => {
+            let mut a = [0u8; 16];
+            a.copy_from_slice(bytes);
+            Some(IpAddr::V6(std::net::Ipv6Addr::from(a)))
+        }
+        _ => None,
+    }
+}
+
+/// getnameinfo(3) wrapper. Devuelve None si no resuelve.
+fn reverse_lookup(ip: IpAddr) -> Option<String> {
+    use std::os::raw::c_char;
+    let mut buf = [0i8; 256];
+    let r = match ip {
+        IpAddr::V4(v4) => unsafe {
+            let octets = v4.octets();
+            let mut sin = std::mem::zeroed::<libc::sockaddr_in>();
+            sin.sin_family = libc::AF_INET as u16;
+            sin.sin_addr = libc::in_addr {
+                s_addr: u32::from_ne_bytes(octets),
+            };
+            libc::getnameinfo(
+                &sin as *const _ as *const libc::sockaddr,
+                std::mem::size_of::<libc::sockaddr_in>() as u32,
+                buf.as_mut_ptr() as *mut c_char, buf.len() as u32,
+                std::ptr::null_mut(), 0,
+                libc::NI_NAMEREQD,
+            )
+        },
+        IpAddr::V6(v6) => unsafe {
+            let octets = v6.octets();
+            let mut sin6 = std::mem::zeroed::<libc::sockaddr_in6>();
+            sin6.sin6_family = libc::AF_INET6 as u16;
+            sin6.sin6_addr.s6_addr.copy_from_slice(&octets);
+            libc::getnameinfo(
+                &sin6 as *const _ as *const libc::sockaddr,
+                std::mem::size_of::<libc::sockaddr_in6>() as u32,
+                buf.as_mut_ptr() as *mut c_char, buf.len() as u32,
+                std::ptr::null_mut(), 0,
+                libc::NI_NAMEREQD,
+            )
+        },
+    };
+    if r != 0 { return None; }
+    let cs = unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) };
+    cs.to_str().ok().map(String::from)
+}
+
+extern crate libc;
+
+async fn announce_to_fractal() {
+    if let Ok(mut client) = BusClient::from_env().await {
+        let req = BusRequest::Announce {
+            capabilities: vec![Capability::Endpoint {
+                interface: ente_card::InterfaceId([0xa3; 16]),
+                version: 1,
+            }],
+        };
+        match client.call(req).await {
+            Ok(BusResponse::Ok) => info!("Announce → bus interno OK"),
+            Ok(other) => warn!(?other, "Announce respuesta inesperada"),
+            Err(e) => warn!(?e, "Announce falló"),
+        }
+    }
+}
+
+async fn wait_for_term() -> anyhow::Result<()> {
+    let mut term = signal(SignalKind::terminate())?;
+    let mut int_ = signal(SignalKind::interrupt())?;
+    tokio::select! {
+        _ = term.recv() => info!("SIGTERM"),
+        _ = int_.recv() => info!("SIGINT"),
+    }
+    Ok(())
+}
+
+fn init_tracing() {
+    let filter = EnvFilter::try_from_default_env()
+        .unwrap_or_else(|_| EnvFilter::new("ente_resolved_compat=info"));
+    tracing_subscriber::fmt().with_env_filter(filter).with_target(true).init();
+}
diff --git a/crates/ente-timedated-compat/src/main.rs b/crates/ente-timedated-compat/src/main.rs
index 1ad2b0b..a224c41 100644
--- a/crates/ente-timedated-compat/src/main.rs
+++ b/crates/ente-timedated-compat/src/main.rs
@@ -105,7 +105,21 @@ impl TimedateManager {
     }
 
     async fn set_timezone(&self, timezone: String, _interactive: bool) -> fdo::Result<()> {
-        info!(%timezone, "SetTimezone (stub: no actualizamos /etc/localtime)");
+        // Validar contra zoneinfo: el archivo destino debe existir.
+        let zoneinfo = format!("/usr/share/zoneinfo/{timezone}");
+        if !std::path::Path::new(&zoneinfo).exists() {
+            return Err(fdo::Error::InvalidArgs(format!("timezone desconocida: {timezone}")));
+        }
+        // Atomic relink: crear localtime.tmp como symlink, rename.
+        let tmp = "/etc/localtime.tmp";
+        let _ = std::fs::remove_file(tmp);
+        if let Err(e) = std::os::unix::fs::symlink(&zoneinfo, tmp) {
+            return Err(fdo::Error::Failed(format!("symlink: {e}")));
+        }
+        if let Err(e) = std::fs::rename(tmp, "/etc/localtime") {
+            return Err(fdo::Error::Failed(format!("rename: {e}")));
+        }
+        info!(%timezone, "SetTimezone → /etc/localtime");
         Ok(())
     }
 
diff --git a/crates/ente-zero/src/seed.rs b/crates/ente-zero/src/seed.rs
index 9dfada5..06365ab 100644
--- a/crates/ente-zero/src/seed.rs
+++ b/crates/ente-zero/src/seed.rs
@@ -153,6 +153,8 @@ fn synthesize_dev_seed() -> EntityCard {
         ("compat-timedated", "target/debug/ente-timedated-compat"),
         ("compat-localed",   "target/debug/ente-localed-compat"),
         ("compat-journald",  "target/debug/ente-journald-compat"),
+        ("compat-resolved",  "target/debug/ente-resolved-compat"),
+        ("compat-polkit",    "target/debug/ente-polkit-compat"),
     ] {
         if let Some(card) = optional_native_card(
             label, bin,