Fase 3: op del watcher (Create/Edit/Delete) derivada del diario
CLOSE_WRITE ya no se materializa siempre como Replace. classify_op deriva: - Delete: el fd resuelve a " (deleted)" (sufijo que ahora recortamos del path en vez de colarlo al diario; content_hash queda None). - Edit: el diario ya tenía un evento previo no-Delete para el path. - Create: primera mutación observada, o resurrección tras un Delete. Es la divergencia que el diario atestigua, no la verdad absoluta del FS. La atribución plena vía FAN_REPORT_DFID_NAME queda diferida: nix 0.30 no parsea los info-records de FID y el modo FID perdería el fd que hashea el contenido. classify_op se extrae como función libre para probarla sin CAP_SYS_ADMIN; 4 tests unitarios nuevos. Roadmap Fase 3 actualizado. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
6c34484fbe
commit
303dfa2b58
+104
-18
@@ -8,9 +8,16 @@
|
||||
//! * Filtro mínimo: ignoramos eventos cuya ruta resuelta cae dentro de un overlay activo
|
||||
//! (consultamos `hammer_overlay::status` en cada batch — para Fase 3 el coste es
|
||||
//! despreciable; cuando los overlays se vuelvan numerosos cachearemos los paths).
|
||||
//! * Cada evento se materializa como `MutationEvent` con `op = Replace` y `source = External`.
|
||||
//! El refinamiento (Create/Edit por contenido, hashing de contenido, atribución a un
|
||||
//! `hydrate` reciente) llega después.
|
||||
//! * Cada evento se materializa como `MutationEvent` con `source = External` y la `op`
|
||||
//! se deriva (`classify_op`): si el fd resuelve a un path borrado (`" (deleted)"`) es
|
||||
//! `Delete`; si el diario ya conocía ese path (con un evento previo no-`Delete`) es
|
||||
//! `Edit`; en otro caso es `Create` (primera mutación que hammer observa sobre el path).
|
||||
//! Nota: `FAN_CLOSE_WRITE` no distingue un `rename`-sobre-destino de una reescritura in
|
||||
//! situ, ni reporta el nombre en eventos de directorio — la atribución plena de
|
||||
//! `Create`/`Move`/`Delete` vía `FAN_REPORT_DFID_NAME` (con `open_by_handle_at` y
|
||||
//! `CAP_DAC_READ_SEARCH`) queda para el track posterior; `nix` 0.30 aún no parsea los
|
||||
//! info-records de FID, así que exigiría libc crudo y perdería el fd que hoy hashea el
|
||||
//! contenido.
|
||||
//!
|
||||
//! Privilegios: `fanotify_init` requiere `CAP_SYS_ADMIN`. Si no lo tenemos, fallamos rápido
|
||||
//! con un mensaje claro y el daemon sigue arrancando los otros subsistemas.
|
||||
@@ -133,7 +140,7 @@ impl Watcher {
|
||||
for ev in events {
|
||||
let pid = ev.pid() as u32;
|
||||
let Some(fd) = ev.fd() else { continue };
|
||||
let path = match path_of_fd(fd) {
|
||||
let (path, deleted) = match path_of_fd(fd) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "watcher: no pude resolver path del fd, saltando");
|
||||
@@ -148,18 +155,23 @@ impl Watcher {
|
||||
continue;
|
||||
}
|
||||
// Hash del contenido tras la mutación: alimenta el de-dup idempotente y, más
|
||||
// tarde, la correlación en `export`. Si no podemos leer el archivo (borrado,
|
||||
// permiso), seguimos con `None` — el evento sigue siendo información válida.
|
||||
let content_hash = match hammer_journal::hash_file(&path) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
tracing::warn!(path = %path.display(), error = %e, "watcher: no pude hashear contenido");
|
||||
None
|
||||
// tarde, la correlación en `export`. Si el archivo se borró (o no podemos leerlo
|
||||
// por permiso) seguimos con `None` — el evento sigue siendo información válida.
|
||||
let content_hash = if deleted {
|
||||
None
|
||||
} else {
|
||||
match hammer_journal::hash_file(&path) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
tracing::warn!(path = %path.display(), error = %e, "watcher: no pude hashear contenido");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
let op = self.classify_op(&path, deleted)?;
|
||||
let me = MutationEvent {
|
||||
ts: hammer_journal::now_rfc3339(),
|
||||
op: MutationOp::Replace,
|
||||
op,
|
||||
path,
|
||||
by: Actor {
|
||||
source: Source::External,
|
||||
@@ -186,6 +198,20 @@ impl Watcher {
|
||||
Ok(recorded)
|
||||
}
|
||||
|
||||
/// Deriva la `op` de un `CLOSE_WRITE` a partir del estado del fd y del diario:
|
||||
///
|
||||
/// * `deleted` (el fd resuelve a `" (deleted)"`) ⇒ `Delete`.
|
||||
/// * el diario ya tiene un evento previo no-`Delete` para el path ⇒ `Edit` (el archivo
|
||||
/// ya era conocido y se reescribió).
|
||||
/// * en otro caso ⇒ `Create` — la primera mutación que hammer observa sobre el path, o
|
||||
/// una resurrección tras un `Delete` previo.
|
||||
///
|
||||
/// No es una verdad absoluta del FS (un archivo preexistente al daemon, editado por
|
||||
/// primera vez, se ve como `Create`); es la divergencia que el diario puede atestiguar.
|
||||
fn classify_op(&self, path: &Path, deleted: bool) -> Result<MutationOp> {
|
||||
classify_op(&self.journal, path, deleted)
|
||||
}
|
||||
|
||||
fn overlay_targets(&self) -> Vec<PathBuf> {
|
||||
let Some(root) = &self.overlay_state_root else {
|
||||
return Vec::new();
|
||||
@@ -204,21 +230,39 @@ impl Watcher {
|
||||
}
|
||||
|
||||
/// Resuelve el path real apuntado por un fd procediendo por `/proc/self/fd/<n>`. Devuelve el
|
||||
/// path destino del symlink.
|
||||
fn path_of_fd(fd: std::os::fd::BorrowedFd<'_>) -> std::io::Result<PathBuf> {
|
||||
/// path destino del symlink y un flag `deleted`: `readlink` añade el sufijo `" (deleted)"`
|
||||
/// cuando el archivo se desvinculó entre el evento y nuestra resolución. Lo recortamos para
|
||||
/// que el path entre limpio al diario y propagamos el flag para clasificar la `op` como
|
||||
/// `Delete`.
|
||||
fn path_of_fd(fd: std::os::fd::BorrowedFd<'_>) -> std::io::Result<(PathBuf, bool)> {
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let link = format!("/proc/self/fd/{}", fd.as_raw_fd());
|
||||
let target = std::fs::read_link(&link)?;
|
||||
// readlink puede devolver paths suffix " (deleted)" si el archivo fue eliminado entre el
|
||||
// evento y nuestra resolución. Los preservamos como están — el journal verá el evento
|
||||
// pero el caller puede filtrarlos si quiere.
|
||||
Ok(target)
|
||||
const SUFFIX: &[u8] = b" (deleted)";
|
||||
let bytes = target.as_os_str().as_bytes();
|
||||
if let Some(trimmed) = bytes.strip_suffix(SUFFIX) {
|
||||
return Ok((PathBuf::from(std::ffi::OsStr::from_bytes(trimmed)), true));
|
||||
}
|
||||
Ok((target, false))
|
||||
}
|
||||
|
||||
fn path_is_under_any(p: &Path, roots: &[PathBuf]) -> bool {
|
||||
roots.iter().any(|r| p.starts_with(r))
|
||||
}
|
||||
|
||||
/// Deriva la `op` de un `CLOSE_WRITE` (ver [`Watcher::classify_op`]). Libre de estado del
|
||||
/// fanotify para poder probarla sin `CAP_SYS_ADMIN`.
|
||||
fn classify_op(journal: &Journal, path: &Path, deleted: bool) -> Result<MutationOp> {
|
||||
if deleted {
|
||||
return Ok(MutationOp::Delete);
|
||||
}
|
||||
Ok(match journal.last_for_path(path)? {
|
||||
Some(ev) if ev.op != MutationOp::Delete => MutationOp::Edit,
|
||||
_ => MutationOp::Create,
|
||||
})
|
||||
}
|
||||
|
||||
/// Lee `/proc/<pid>/status` y extrae el UID real. Devuelve `None` si el proceso desapareció.
|
||||
fn uid_of_pid(pid: u32) -> Option<u32> {
|
||||
let bytes = std::fs::read(format!("/proc/{pid}/status")).ok()?;
|
||||
@@ -255,6 +299,48 @@ mod tests {
|
||||
assert!(!path_is_under_any(Path::new("/binx/y"), &roots));
|
||||
}
|
||||
|
||||
fn me(op: MutationOp, path: &str, hash: Option<&str>) -> MutationEvent {
|
||||
MutationEvent {
|
||||
ts: hammer_journal::now_rfc3339(),
|
||||
op,
|
||||
path: PathBuf::from(path),
|
||||
by: Actor { source: Source::External, pid: Some(1), uid: Some(0) },
|
||||
content_hash: hash.map(str::to_string),
|
||||
note: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_op_deleted_is_delete() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let j = Journal::open(d.path()).unwrap();
|
||||
assert_eq!(classify_op(&j, Path::new("/etc/x"), true).unwrap(), MutationOp::Delete);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_op_unknown_path_is_create() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let j = Journal::open(d.path()).unwrap();
|
||||
assert_eq!(classify_op(&j, Path::new("/bin/new"), false).unwrap(), MutationOp::Create);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_op_known_path_is_edit() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let j = Journal::open(d.path()).unwrap();
|
||||
j.record(&me(MutationOp::Create, "/etc/hosts", Some("b3:aaa"))).unwrap();
|
||||
assert_eq!(classify_op(&j, Path::new("/etc/hosts"), false).unwrap(), MutationOp::Edit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_op_resurrection_after_delete_is_create() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let j = Journal::open(d.path()).unwrap();
|
||||
j.record(&me(MutationOp::Create, "/etc/svc", Some("b3:aaa"))).unwrap();
|
||||
j.record(&me(MutationOp::Delete, "/etc/svc", None)).unwrap();
|
||||
assert_eq!(classify_op(&j, Path::new("/etc/svc"), false).unwrap(), MutationOp::Create);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uid_of_self_matches() {
|
||||
let me = std::process::id();
|
||||
|
||||
+8
-1
@@ -54,7 +54,14 @@ pre-requisito de validación.
|
||||
CAP_SYS_ADMIN.
|
||||
- [x] `hammer journal [--tail N] [--follow] [--format pretty|json]`.
|
||||
- [x] `hammer commit` registra cada archivo promocionado vía `commit_with_journal`.
|
||||
- [ ] Refinar op del watcher (Create vs Replace vs Edit con FAN_REPORT_DFID_NAME).
|
||||
- [x] Refinar la `op` del watcher. `watcher::classify_op(&journal, path, deleted)` deriva
|
||||
`Delete` (el fd resuelve a `" (deleted)"`, que ahora recortamos del path en vez de
|
||||
colarlo al diario), `Edit` (el diario ya tenía un evento previo no-`Delete` del path) o
|
||||
`Create` (primera mutación observada, o resurrección tras un `Delete`). Es la divergencia
|
||||
que el diario atestigua, no la verdad absoluta del FS. La atribución plena vía
|
||||
`FAN_REPORT_DFID_NAME` (nombre en eventos de directorio, `rename`-sobre-destino,
|
||||
`open_by_handle_at`+`CAP_DAC_READ_SEARCH`) queda para el track posterior: `nix` 0.30 no
|
||||
parsea los info-records de FID y el modo FID perdería el fd que hoy hashea el contenido.
|
||||
- [x] Hash del contenido tras la mutación (`content_hash`) y de-dup idempotente.
|
||||
`hammer_journal::content_hash_of`/`hash_file` (blake3 plano, estilo `b3sum`, distinto del
|
||||
`of_inputs` de artefactos). `Journal::record_dedup` omite el evento si deja el archivo
|
||||
|
||||
Reference in New Issue
Block a user