gioser

ente-policy-provider, ente-tmpfiles-compat, journald export format

- ente-policy-provider (nuevo): BusServer que se anuncia como
  POLKIT_DECISION_IFACE provider. Decode blob (pid_be|uid_be|action_id),
  consulta /etc/ente/policy.json (o defaults), responde [allow|deny].
  Reglas con glob simple (foo.* / *.bar / *), require_uid/require_pid,
  audit flag para logging estructurado. Defaults conservadores: hostname/
  timezone/locale set requieren uid 0.

- ente-tmpfiles-compat (nuevo): aplica directivas de
  /usr/lib/tmpfiles.d, /etc/tmpfiles.d, /run/tmpfiles.d (last-wins).
  Soporta d/D/f/L/r/R/e. Orden: removes → creates → adjusts. lookup_uid
  resuelve usuarios via getpwnam. EPERM en chown silenciado en dev
  (esperado sin root). OneShot.

- journald export format: ente-journalctl gana --output=export
  produce systemd journal export format compatible con
  `journalctl --input-format=export -m`. Fields:
  __CURSOR/__REALTIME_TIMESTAMP/_HOSTNAME/_TRANSPORT, native KEY=value
  preservados, syslog text → MESSAGE=. Filter de bytes seguros
  (ASCII printable + tab) para evitar export multipart binario.

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

883c14dadec4b7999d6990ee40338553eccfd397 · ruwaq Sergio · (5 killa ñawpaq) · tayta 48e41331a · kay commitpi sach'ata qhaway

Semántico sayk'uy · 38

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

crates/ente-policy-provider/src/main.rs

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

crates/ente-zero/src/seed.rs

8 qillqakuna · +667 −8

diff --git a/Cargo.lock b/Cargo.lock
index 0d2edba..6970d6f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -459,6 +459,20 @@ dependencies = [
  "zbus",
 ]
 
+[[package]]
+name = "ente-policy-provider"
+version = "0.0.1"
+dependencies = [
+ "anyhow",
+ "ente-bus",
+ "ente-card",
+ "serde",
+ "serde_json",
+ "tokio",
+ "tracing",
+ "tracing-subscriber",
+]
+
 [[package]]
 name = "ente-polkit-compat"
 version = "0.0.1"
@@ -522,6 +536,17 @@ dependencies = [
  "zbus",
 ]
 
+[[package]]
+name = "ente-tmpfiles-compat"
+version = "0.0.1"
+dependencies = [
+ "anyhow",
+ "libc",
+ "nix",
+ "tracing",
+ "tracing-subscriber",
+]
+
 [[package]]
 name = "ente-wasm"
 version = "0.0.1"
diff --git a/Cargo.toml b/Cargo.toml
index 99bef85..3b3540a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -19,6 +19,8 @@ members = [
     "crates/ente-resolved-compat",
     "crates/ente-polkit-compat",
     "crates/ente-machined-compat",
+    "crates/ente-policy-provider",
+    "crates/ente-tmpfiles-compat",
 ]
 
 [workspace.package]
diff --git a/crates/ente-journald-compat/src/journalctl.rs b/crates/ente-journald-compat/src/journalctl.rs
index c282228..8164bea 100644
--- a/crates/ente-journald-compat/src/journalctl.rs
+++ b/crates/ente-journald-compat/src/journalctl.rs
@@ -20,13 +20,24 @@ struct Args {
     grep: Option<String>,
     tail: Option<usize>,
     source: Option<String>,
-    json: bool,
+    output: OutputFormat,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum OutputFormat {
+    Pretty,
+    Json,
+    /// systemd journal export format: `KEY=value\n` por field, blank line
+    /// entre entries. Documented at https://systemd.io/JOURNAL_EXPORT_FORMATS/
+    /// Compatible con `journalctl --input-format=export`.
+    Export,
 }
 
 fn parse_args() -> Args {
     let mut args = std::env::args().skip(1);
     let mut a = Args {
-        unit: None, since_secs: None, grep: None, tail: None, source: None, json: false,
+        unit: None, since_secs: None, grep: None, tail: None,
+        source: None, output: OutputFormat::Pretty,
     };
     while let Some(arg) = args.next() {
         match arg.as_str() {
@@ -35,7 +46,19 @@ fn parse_args() -> Args {
             "--grep" | "-g" => a.grep = args.next(),
             "--tail" | "-n" => a.tail = args.next().and_then(|s| s.parse().ok()),
             "--source" => a.source = args.next(),
-            "--json" => a.json = true,
+            "--json" => a.output = OutputFormat::Json,
+            "--output" | "-o" => {
+                a.output = match args.next().as_deref() {
+                    Some("pretty") | None => OutputFormat::Pretty,
+                    Some("json") | Some("json-lines") => OutputFormat::Json,
+                    Some("export") => OutputFormat::Export,
+                    Some(other) => {
+                        eprintln!("output desconocido: {other}");
+                        eprintln!("válidos: pretty | json | export");
+                        std::process::exit(2);
+                    }
+                };
+            }
             "-h" | "--help" => { print_help(); std::process::exit(0); }
             other => {
                 eprintln!("argumento desconocido: {other}");
@@ -57,7 +80,8 @@ fn print_help() {
     eprintln!("  --grep, -g <text>    Contiene <text> en el body decoded");
     eprintln!("  --tail, -n <N>       Últimas N entries");
     eprintln!("Output:");
-    eprintln!("  --json               JSON-lines en lugar de pretty");
+    eprintln!("  --output, -o <fmt>   pretty | json | export (systemd journal export)");
+    eprintln!("  --json               alias de --output json");
 }
 
 fn index_path() -> PathBuf {
@@ -146,10 +170,10 @@ fn main() -> anyhow::Result<()> {
     }
 
     for (e, body) in out {
-        if args.json {
-            print_json(&e, &body);
-        } else {
-            print_pretty(&e, &body);
+        match args.output {
+            OutputFormat::Pretty => print_pretty(&e, &body),
+            OutputFormat::Json => print_json(&e, &body),
+            OutputFormat::Export => print_export(&e, &body),
         }
     }
     Ok(())
@@ -180,6 +204,74 @@ fn print_pretty(e: &IndexEntry, body: &str) {
     }
 }
 
+/// systemd journal export format. Cada entry es un bloque de líneas
+/// `KEY=value\n` separado por blank line. Para values con newlines o
+/// bytes binarios, el formato usa una variante con length-prefix
+/// (8 bytes LE u64) — por simplicidad sólo emitimos values con texto
+/// que no contienen newlines o caracteres no-printables. Extraemos
+/// MESSAGE/PRIORITY/_SYSTEMD_UNIT del body si es journald native.
+///
+/// Compatible con `journalctl --input-format=export -m`.
+fn print_export(e: &IndexEntry, body: &str) {
+    // Timestamps: __REALTIME_TIMESTAMP en µs, __MONOTONIC_TIMESTAMP también.
+    let realtime_us = e.timestamp_ms.saturating_mul(1000);
+    println!("__CURSOR=s={};t={};x={}",
+        &e.sha_hex[..16],     // pseudo-cursor: prefix del SHA
+        realtime_us,
+        &e.sha_hex[..8]);
+    println!("__REALTIME_TIMESTAMP={}", realtime_us);
+    println!("__MONOTONIC_TIMESTAMP={}", realtime_us);
+
+    let host = gethostname_safe();
+    if !host.is_empty() {
+        println!("_HOSTNAME={host}");
+    }
+
+    if e.unit != "-" {
+        println!("_SYSTEMD_UNIT={}", e.unit);
+    }
+    println!("_TRANSPORT={}", match e.source.as_str() {
+        "syslog" => "syslog",
+        "journal" => "journal",
+        _ => "stdout",
+    });
+
+    // Si el body es journald native (KEY=value lines), emitir cada uno
+    // verbatim — son los fields originales del producer. Filtrar líneas
+    // que no son seguras para export (con newlines en value, etc).
+    if body.contains('=') && body.lines().any(|l| l.contains('=')) {
+        for line in body.lines() {
+            if line.contains('=') && line.bytes().all(safe_export_byte) {
+                println!("{line}");
+            }
+        }
+    } else {
+        // Syslog text — empaquetar como MESSAGE.
+        let msg = body.trim_end()
+            .replace('\n', " ");  // collapsa newlines
+        if msg.bytes().all(safe_export_byte) {
+            println!("MESSAGE={msg}");
+        }
+    }
+    // Blank line separa entries.
+    println!();
+}
+
+fn safe_export_byte(b: u8) -> bool {
+    // ASCII printable, espacio, tab. No newlines (manejados aparte).
+    (0x20..=0x7E).contains(&b) || b == b'\t'
+}
+
+fn gethostname_safe() -> String {
+    let mut buf = [0u8; 256];
+    let r = unsafe {
+        libc::gethostname(buf.as_mut_ptr() as *mut _, buf.len())
+    };
+    if r != 0 { return String::new(); }
+    let len = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
+    std::str::from_utf8(&buf[..len]).unwrap_or("").to_string()
+}
+
 fn print_json(e: &IndexEntry, body: &str) {
     // JSON-lines básico, sin dependencia de serde — formato simple y estable.
     let escaped_body = body
diff --git a/crates/ente-policy-provider/Cargo.toml b/crates/ente-policy-provider/Cargo.toml
new file mode 100644
index 0000000..9b2e83c
--- /dev/null
+++ b/crates/ente-policy-provider/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "ente-policy-provider"
+version = "0.0.1"
+edition.workspace = true
+license.workspace = true
+publish.workspace = true
+
+[[bin]]
+name = "ente-policy-provider"
+path = "src/main.rs"
+
+[dependencies]
+ente-card = { path = "../ente-card" }
+ente-bus = { path = "../ente-bus" }
+serde = { workspace = true }
+serde_json = { workspace = true }
+anyhow = { workspace = true }
+tokio = { workspace = true }
+tracing = { workspace = true }
+tracing-subscriber = { workspace = true }
diff --git a/crates/ente-policy-provider/src/main.rs b/crates/ente-policy-provider/src/main.rs
new file mode 100644
index 0000000..3777ddc
--- /dev/null
+++ b/crates/ente-policy-provider/src/main.rs
@@ -0,0 +1,221 @@
+//! ente-policy-provider: Ente que arbitra autorizaciones de Polkit.
+//!
+//! Se anuncia como proveedor de `POLKIT_DECISION_IFACE` en el bus interno.
+//! Cuando `ente-polkit-compat` recibe `CheckAuthorization` D-Bus, forwarda
+//! a este Ente vía Invoke. Aquí decidimos sí/no según política configurada.
+//!
+//! Wire format del blob de entrada: `pid_be_u32 | uid_be_u32 | action_id_utf8`.
+//! Respuesta: `[decision_byte]` — 1 = allow, 0 = deny.
+//!
+//! Política se carga de `/etc/ente/policy.json` (o ruta override por env
+//! `ENTE_POLICY_FILE`). Formato:
+//! ```json
+//! {
+//!   "default": "allow",
+//!   "rules": [
+//!     { "match": "org.freedesktop.hostname1.*", "decision": "allow" },
+//!     { "match": "org.freedesktop.login1.power-off", "require_uid": 0 },
+//!     { "match": "*.set-*", "decision": "deny", "audit": true }
+//!   ]
+//! }
+//! ```
+
+use ente_bus::{BusResponse, BusServer, InvokeHandler, POLKIT_DECISION_IFACE};
+use ente_card::Capability;
+use serde::Deserialize;
+use std::sync::Arc;
+use tokio::signal::unix::{signal, SignalKind};
+use tracing::{info, warn};
+use tracing_subscriber::EnvFilter;
+
+#[derive(Debug, Clone, Deserialize)]
+struct PolicyConfig {
+    #[serde(default = "default_decision")]
+    default: Decision,
+    #[serde(default)]
+    rules: Vec<Rule>,
+}
+
+fn default_decision() -> Decision { Decision::Allow }
+
+#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "lowercase")]
+enum Decision { Allow, Deny }
+
+#[derive(Debug, Clone, Deserialize)]
+struct Rule {
+    /// Glob simple: `*` = wildcard. `org.freedesktop.hostname1.*` matchea
+    /// cualquier action_id con ese prefijo.
+    r#match: String,
+    #[serde(default)]
+    decision: Option<Decision>,
+    /// Si presente, sólo este uid pasa. Otros se denegen.
+    #[serde(default)]
+    require_uid: Option<u32>,
+    /// Si presente, sólo este pid pasa.
+    #[serde(default)]
+    require_pid: Option<u32>,
+    #[serde(default)]
+    audit: bool,
+}
+
+impl Default for PolicyConfig {
+    fn default() -> Self {
+        // Default sensato: caps escaladas requieren uid 0; el resto allow.
+        Self {
+            default: Decision::Allow,
+            rules: vec![
+                // Power management: cualquiera puede pedir el reboot,
+                // pero la decisión final está en el holder de Capability::Spawn.
+                Rule {
+                    r#match: "org.freedesktop.login1.set-wall-message".into(),
+                    decision: Some(Decision::Allow), require_uid: None, require_pid: None, audit: true,
+                },
+                // hostname/timezone/locale: requieren root.
+                Rule {
+                    r#match: "org.freedesktop.hostname1.*".into(),
+                    decision: None, require_uid: Some(0), require_pid: None, audit: true,
+                },
+                Rule {
+                    r#match: "org.freedesktop.timedate1.*".into(),
+                    decision: None, require_uid: Some(0), require_pid: None, audit: true,
+                },
+                Rule {
+                    r#match: "org.freedesktop.locale1.*".into(),
+                    decision: None, require_uid: Some(0), require_pid: None, audit: true,
+                },
+            ],
+        }
+    }
+}
+
+#[tokio::main(flavor = "current_thread")]
+async fn main() -> anyhow::Result<()> {
+    init_tracing();
+    info!("ente-policy-provider: arrancando");
+
+    let policy = load_policy();
+    info!(rules = policy.rules.len(), default = ?policy.default, "policy cargada");
+
+    let handler = PolicyHandler { policy: Arc::new(policy) };
+
+    tokio::spawn(async {
+        let mut term = signal(SignalKind::terminate()).unwrap();
+        let mut int_ = signal(SignalKind::interrupt()).unwrap();
+        tokio::select! {
+            _ = term.recv() => info!("SIGTERM"),
+            _ = int_.recv() => info!("SIGINT"),
+        }
+        std::process::exit(0);
+    });
+
+    // Una única conexión: announce + serve. Bidirectional bajo el hood.
+    let mut server = BusServer::from_env().await?;
+    server.announce(vec![Capability::Endpoint {
+        interface: POLKIT_DECISION_IFACE,
+        version: 1,
+    }]).await?;
+    info!("Announce OK; sirviendo invokes de policy decision");
+    server.serve(handler).await?;
+    Ok(())
+}
+
+struct PolicyHandler {
+    policy: Arc<PolicyConfig>,
+}
+
+impl InvokeHandler for PolicyHandler {
+    fn handle(&mut self, cap: Capability, blob: Vec<u8>) -> BusResponse {
+        // Validar cap (defensa contra forwarding a interface incorrecto).
+        if !matches!(&cap, Capability::Endpoint { interface, .. } if *interface == POLKIT_DECISION_IFACE) {
+            return BusResponse::Error(format!("policy-provider: cap inesperado {cap:?}"));
+        }
+        // Decodificar blob: [pid:4][uid:4][action_id...]
+        if blob.len() < 8 {
+            return BusResponse::Error("blob demasiado corto (esperado pid|uid|action_id)".into());
+        }
+        let pid = u32::from_be_bytes(blob[0..4].try_into().unwrap());
+        let uid = u32::from_be_bytes(blob[4..8].try_into().unwrap());
+        let action_id = match std::str::from_utf8(&blob[8..]) {
+            Ok(s) => s,
+            Err(_) => return BusResponse::Error("action_id no es UTF-8".into()),
+        };
+
+        let decision = decide(&self.policy, action_id, pid, uid);
+        let byte = if decision == Decision::Allow { 1u8 } else { 0u8 };
+        info!(action_id, pid, uid, ?decision, "policy decision");
+        BusResponse::Invoked { result: vec![byte] }
+    }
+}
+
+fn decide(policy: &PolicyConfig, action_id: &str, pid: u32, uid: u32) -> Decision {
+    for rule in &policy.rules {
+        if !glob_match(&rule.r#match, action_id) { continue; }
+        if let Some(req_uid) = rule.require_uid {
+            if uid != req_uid {
+                if rule.audit {
+                    info!(action_id, uid, req_uid, "AUDIT: deny por uid mismatch");
+                }
+                return Decision::Deny;
+            }
+        }
+        if let Some(req_pid) = rule.require_pid {
+            if pid != req_pid {
+                if rule.audit {
+                    info!(action_id, pid, req_pid, "AUDIT: deny por pid mismatch");
+                }
+                return Decision::Deny;
+            }
+        }
+        if let Some(d) = rule.decision {
+            if rule.audit {
+                info!(action_id, ?d, "AUDIT: rule match con decisión explícita");
+            }
+            return d;
+        }
+        // Rule matched pero sin decisión explícita (sólo require_*) y todos
+        // los requires pasaron — caemos al default.
+        if rule.audit {
+            info!(action_id, ?policy.default, "AUDIT: rule match → default");
+        }
+        return policy.default;
+    }
+    policy.default
+}
+
+/// Glob simple: `*` matchea cualquier cosa. Soporta prefix (`foo.*`),
+/// suffix (`*.bar`) y wildcard exacto (`*`). No es PCRE — intencional.
+fn glob_match(pattern: &str, target: &str) -> bool {
+    if pattern == "*" { return true; }
+    if let Some(prefix) = pattern.strip_suffix(".*") {
+        return target == prefix || target.starts_with(&format!("{prefix}."));
+    }
+    if let Some(suffix) = pattern.strip_prefix("*.") {
+        return target == suffix || target.ends_with(&format!(".{suffix}"));
+    }
+    pattern == target
+}
+
+fn load_policy() -> PolicyConfig {
+    let path = std::env::var("ENTE_POLICY_FILE")
+        .unwrap_or_else(|_| "/etc/ente/policy.json".into());
+    match std::fs::read_to_string(&path) {
+        Ok(content) => match serde_json::from_str(&content) {
+            Ok(p) => { info!(path, "policy file cargado"); p }
+            Err(e) => {
+                warn!(?e, path, "policy file inválido, usando defaults");
+                PolicyConfig::default()
+            }
+        },
+        Err(_) => {
+            info!(path, "policy file ausente — usando defaults conservadores");
+            PolicyConfig::default()
+        }
+    }
+}
+
+fn init_tracing() {
+    let filter = EnvFilter::try_from_default_env()
+        .unwrap_or_else(|_| EnvFilter::new("ente_policy_provider=info"));
+    tracing_subscriber::fmt().with_env_filter(filter).with_target(true).init();
+}
diff --git a/crates/ente-tmpfiles-compat/Cargo.toml b/crates/ente-tmpfiles-compat/Cargo.toml
new file mode 100644
index 0000000..b3b6123
--- /dev/null
+++ b/crates/ente-tmpfiles-compat/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "ente-tmpfiles-compat"
+version = "0.0.1"
+edition.workspace = true
+license.workspace = true
+publish.workspace = true
+
+[[bin]]
+name = "ente-tmpfiles-compat"
+path = "src/main.rs"
+
+[dependencies]
+nix = { workspace = true }
+libc = { workspace = true }
+anyhow = { workspace = true }
+tracing = { workspace = true }
+tracing-subscriber = { workspace = true }
diff --git a/crates/ente-tmpfiles-compat/src/main.rs b/crates/ente-tmpfiles-compat/src/main.rs
new file mode 100644
index 0000000..58de1c4
--- /dev/null
+++ b/crates/ente-tmpfiles-compat/src/main.rs
@@ -0,0 +1,281 @@
+//! ente-tmpfiles-compat: aplica directivas tmpfiles.d al boot.
+//!
+//! Lee, en orden, los conf files de:
+//!   /usr/lib/tmpfiles.d/*.conf
+//!   /etc/tmpfiles.d/*.conf       (override del usuario, gana)
+//!   /run/tmpfiles.d/*.conf       (efímero)
+//!
+//! Aplica un subset de directivas — las suficientes para el boot:
+//!   d  — crear directorio (idempotente: no falla si existe)
+//!   D  — crear directorio + limpiar contenido si existe
+//!   f  — crear archivo (vacío, perms aplicados)
+//!   L  — crear symlink (overrideable con `+L` si existe)
+//!   r  — remove file (no falla si ausente)
+//!   R  — remove recursivamente
+//!   e  — adjust perms si existe
+//!
+//! Edad/cleanup (`age` field) y modos exotic (b, c, p, P) se ignoran.
+//! El proceso es OneShot: corre, aplica, sale con código 0 / 1.
+
+use std::collections::BTreeMap;
+use std::fs;
+use std::os::unix::fs::PermissionsExt;
+use std::path::{Path, PathBuf};
+use tracing::{debug, info, warn};
+use tracing_subscriber::EnvFilter;
+
+const SEARCH_DIRS: &[&str] = &[
+    "/usr/lib/tmpfiles.d",
+    "/etc/tmpfiles.d",
+    "/run/tmpfiles.d",
+];
+
+#[derive(Debug, Clone)]
+struct Directive {
+    typ: char,           // d, D, f, L, r, R, e
+    path: PathBuf,
+    mode: Option<u32>,
+    user: Option<String>,
+    group: Option<String>,
+    arg: Option<String>, // symlink target o content
+}
+
+fn main() {
+    init_tracing();
+    info!("ente-tmpfiles-compat: aplicando directivas tmpfiles.d");
+    let directives = collect_directives();
+    info!(count = directives.len(), "directivas a aplicar");
+
+    let mut applied = 0;
+    let mut skipped = 0;
+    let mut errors = 0;
+    for d in directives {
+        match apply(&d) {
+            Ok(true) => applied += 1,
+            Ok(false) => skipped += 1,
+            Err(e) => {
+                warn!(?e, ?d.typ, path = %d.path.display(), "directiva falló");
+                errors += 1;
+            }
+        }
+    }
+    info!(applied, skipped, errors, "tmpfiles aplicado");
+    if errors > 0 { std::process::exit(1); }
+}
+
+fn collect_directives() -> Vec<Directive> {
+    // Last-wins por path: /etc supera /usr/lib, /run supera /etc.
+    let mut by_path: BTreeMap<(PathBuf, char), Directive> = BTreeMap::new();
+    for dir in SEARCH_DIRS {
+        if !Path::new(dir).exists() { continue; }
+        let mut entries: Vec<_> = match fs::read_dir(dir) {
+            Ok(rd) => rd.filter_map(|e| e.ok()).collect(),
+            Err(_) => continue,
+        };
+        entries.sort_by_key(|e| e.file_name());
+        for entry in entries {
+            let path = entry.path();
+            if path.extension().map(|e| e != "conf").unwrap_or(true) { continue; }
+            match fs::read_to_string(&path) {
+                Ok(content) => {
+                    for (line_no, line) in content.lines().enumerate() {
+                        if let Some(d) = parse_line(line) {
+                            by_path.insert((d.path.clone(), d.typ), d);
+                        } else if !line.trim().is_empty() && !line.trim().starts_with('#') {
+                            debug!(file = %path.display(), line_no, line, "no parseable, skip");
+                        }
+                    }
+                }
+                Err(e) => warn!(?e, path = %path.display(), "read"),
+            }
+        }
+    }
+    // Orden de aplicación: removes (r/R) primero, luego creates (d/D/f/L),
+    // adjusts (e) al final.
+    let mut all: Vec<Directive> = by_path.into_values().collect();
+    all.sort_by_key(|d| match d.typ {
+        'r' | 'R' => 0,
+        'd' | 'D' | 'f' | 'L' => 1,
+        'e' => 2,
+        _ => 3,
+    });
+    all
+}
+
+fn parse_line(line: &str) -> Option<Directive> {
+    let line = line.trim();
+    if line.is_empty() || line.starts_with('#') { return None; }
+    // Formato: TYPE PATH MODE USER GROUP AGE ARGUMENT
+    // Strip leading '+' (override marker) y '!' (boot-only) — los soportamos
+    // implícitamente.
+    let typ_str = line.chars().next()?;
+    let typ = match typ_str {
+        '+' | '!' => line.chars().nth(1)?,
+        c => c,
+    };
+    if !"dDfLrRe".contains(typ) { return None; }
+    // tokenize tomando en cuenta '-' como "default"
+    let mut parts = line.splitn(7, char::is_whitespace).filter(|s| !s.is_empty());
+    let _t = parts.next()?;
+    let path = parts.next()?.to_string();
+    let mode = parts.next().and_then(parse_mode);
+    let user = parts.next().and_then(parse_default);
+    let group = parts.next().and_then(parse_default);
+    let _age = parts.next();
+    let arg = parts.next().and_then(parse_default);
+    Some(Directive {
+        typ,
+        path: PathBuf::from(path),
+        mode, user, group, arg,
+    })
+}
+
+fn parse_default(s: &str) -> Option<String> {
+    if s == "-" { None } else { Some(s.to_string()) }
+}
+
+fn parse_mode(s: &str) -> Option<u32> {
+    if s == "-" { return None; }
+    u32::from_str_radix(s.trim_start_matches('~'), 8).ok()
+}
+
+fn apply(d: &Directive) -> anyhow::Result<bool> {
+    match d.typ {
+        'd' | 'D' => apply_d(d),
+        'f' => apply_f(d),
+        'L' => apply_l(d),
+        'r' => apply_r(d, false),
+        'R' => apply_r(d, true),
+        'e' => apply_e(d),
+        _ => Ok(false),
+    }
+}
+
+fn apply_d(d: &Directive) -> anyhow::Result<bool> {
+    fs::create_dir_all(&d.path)
+        .map_err(|e| anyhow::anyhow!("mkdir {}: {e}", d.path.display()))?;
+    if let Some(mode) = d.mode {
+        fs::set_permissions(&d.path, fs::Permissions::from_mode(mode))?;
+    }
+    chown(&d.path, d.user.as_deref(), d.group.as_deref())?;
+    if d.typ == 'D' {
+        // Limpiar contenido (no recursivo).
+        if let Ok(rd) = fs::read_dir(&d.path) {
+            for entry in rd.flatten() {
+                let p = entry.path();
+                if p.is_dir() { let _ = fs::remove_dir_all(&p); }
+                else { let _ = fs::remove_file(&p); }
+            }
+        }
+    }
+    info!(path = %d.path.display(), mode = ?d.mode, "d/D aplicado");
+    Ok(true)
+}
+
+fn apply_f(d: &Directive) -> anyhow::Result<bool> {
+    if !d.path.exists() {
+        if let Some(parent) = d.path.parent() {
+            let _ = fs::create_dir_all(parent);
+        }
+        let content = d.arg.clone().unwrap_or_default();
+        fs::write(&d.path, content.as_bytes())?;
+    }
+    if let Some(mode) = d.mode {
+        fs::set_permissions(&d.path, fs::Permissions::from_mode(mode))?;
+    }
+    chown(&d.path, d.user.as_deref(), d.group.as_deref())?;
+    info!(path = %d.path.display(), mode = ?d.mode, "f aplicado");
+    Ok(true)
+}
+
+fn apply_l(d: &Directive) -> anyhow::Result<bool> {
+    let target = match &d.arg {
+        Some(t) => t,
+        None => anyhow::bail!("L sin target en {}", d.path.display()),
+    };
+    if d.path.exists() {
+        // No sobreescribimos symlinks/files existentes (modo no-`+`).
+        debug!(path = %d.path.display(), "L: existe, skip");
+        return Ok(false);
+    }
+    if let Some(parent) = d.path.parent() {
+        let _ = fs::create_dir_all(parent);
+    }
+    std::os::unix::fs::symlink(target, &d.path)?;
+    info!(path = %d.path.display(), %target, "L aplicado");
+    Ok(true)
+}
+
+fn apply_r(d: &Directive, recursive: bool) -> anyhow::Result<bool> {
+    if !d.path.exists() {
+        return Ok(false);
+    }
+    if recursive {
+        fs::remove_dir_all(&d.path)?;
+    } else if d.path.is_dir() {
+        fs::remove_dir(&d.path)?;
+    } else {
+        fs::remove_file(&d.path)?;
+    }
+    info!(path = %d.path.display(), recursive, "remove aplicado");
+    Ok(true)
+}
+
+fn apply_e(d: &Directive) -> anyhow::Result<bool> {
+    if !d.path.exists() {
+        return Ok(false);
+    }
+    if let Some(mode) = d.mode {
+        fs::set_permissions(&d.path, fs::Permissions::from_mode(mode))?;
+    }
+    chown(&d.path, d.user.as_deref(), d.group.as_deref())?;
+    info!(path = %d.path.display(), "e aplicado");
+    Ok(true)
+}
+
+fn chown(path: &Path, user: Option<&str>, group: Option<&str>) -> anyhow::Result<()> {
+    use std::ffi::CString;
+    let uid = match user {
+        Some(u) => Some(lookup_uid(u)?),
+        None => None,
+    };
+    let gid = match group {
+        Some(g) => Some(lookup_gid(g)?),
+        None => None,
+    };
+    let (uid, gid) = (uid.unwrap_or(u32::MAX), gid.unwrap_or(u32::MAX));
+    let cstr = CString::new(path.as_os_str().as_encoded_bytes())?;
+    let r = unsafe { libc::chown(cstr.as_ptr(), uid, gid) };
+    if r != 0 {
+        let e = std::io::Error::last_os_error();
+        // No-op si ya somos non-root y el chown falla con EPERM.
+        if e.raw_os_error() == Some(libc::EPERM) {
+            debug!(path = %path.display(), "chown EPERM (esperado sin root)");
+            return Ok(());
+        }
+        return Err(anyhow::anyhow!("chown: {e}"));
+    }
+    Ok(())
+}
+
+fn lookup_uid(name: &str) -> anyhow::Result<u32> {
+    if let Ok(n) = name.parse::<u32>() { return Ok(n); }
+    let cstr = std::ffi::CString::new(name)?;
+    let pw = unsafe { libc::getpwnam(cstr.as_ptr()) };
+    if pw.is_null() { anyhow::bail!("user '{name}' no encontrado"); }
+    Ok(unsafe { (*pw).pw_uid })
+}
+
+fn lookup_gid(name: &str) -> anyhow::Result<u32> {
+    if let Ok(n) = name.parse::<u32>() { return Ok(n); }
+    let cstr = std::ffi::CString::new(name)?;
+    let gr = unsafe { libc::getgrnam(cstr.as_ptr()) };
+    if gr.is_null() { anyhow::bail!("group '{name}' no encontrado"); }
+    Ok(unsafe { (*gr).gr_gid })
+}
+
+fn init_tracing() {
+    let filter = EnvFilter::try_from_default_env()
+        .unwrap_or_else(|_| EnvFilter::new("ente_tmpfiles_compat=info"));
+    tracing_subscriber::fmt().with_env_filter(filter).with_target(true).init();
+}
diff --git a/crates/ente-zero/src/seed.rs b/crates/ente-zero/src/seed.rs
index f424982..5d23bc2 100644
--- a/crates/ente-zero/src/seed.rs
+++ b/crates/ente-zero/src/seed.rs
@@ -156,6 +156,7 @@ fn synthesize_dev_seed() -> EntityCard {
         ("compat-resolved",  "target/debug/ente-resolved-compat"),
         ("compat-polkit",    "target/debug/ente-polkit-compat"),
         ("compat-machined",  "target/debug/ente-machined-compat"),
+        ("policy-provider",  "target/debug/ente-policy-provider"),
     ] {
         if let Some(card) = optional_native_card(
             label, bin,