← Sistemas de Archivos y E/SBloque 14 · Integración y cierre · Tema 49 de 50

Tema 49

Proyecto: respaldo incremental con enlaces duros

Construiremos versiones completas de un árbol de archivos que comparten los datos sin cambios mediante enlaces duros. El proyecto verifica contenido, separa metadatos de almacenamiento, publica cada versión al terminar y permite restaurarla a un destino nuevo. Código descargable, pruebas de conservación e identidad y dos simuladores mostrarán qué se comparte y qué debe permanecer independiente.

1. Versiones completas con almacenamiento incremental

El tema 48 integró estructuras propias dentro de una imagen. Ahora utilizaremos el sistema de archivos del host para organizar versiones y compartir archivos. Recuperaremos las ideas de identidad del inodo, enlaces, actualización atómica y sincronización.

Versión completa: árbol que contiene todos los nombres y datos de un instante de respaldo. Almacenamiento incremental del proyecto: los archivos sin cambios pueden compartir un inodo con la versión anterior, mientras los nuevos o modificados reciben una copia independiente.

Una versión no es una lista de diferencias que deba reproducirse sobre toda la cadena de antecesoras. Dispone de su árbol completo y manifiesto. El nombre de la versión anterior documenta su procedencia, pero no es necesario abrirla para verificar o restaurar esa versión.

El proyecto es una herramienta didáctica para Linux, árboles de laboratorio estables y un repositorio propio. No instala un servicio ni modifica automáticamente carpetas existentes. Incluye creación, verificación y restauración; la política de retención se estudia por separado.

2. Qué comparten realmente dos enlaces duros

Un enlace duro agrega otro nombre al mismo inodo dentro del sistema de archivos. Dos rutas que comparten st_dev y st_ino comparten el objeto, no dos copias que se sincronizan después. El contador st_nlink informa cuántos enlaces de nombre tiene el inodo en ese momento.

Archivos sin cambios compartidos entre versionesLos nombres v1 y v2 para datos.bin apuntan al mismo inodo, mientras las dos versiones de nota.txt apuntan a inodos distintos porque cambió el contenido. v1/data/datos.binnombre de la versión 1 v2/data/datos.binnombre de la versión 2 Mismo (st_dev, st_ino)datos y metadatos físicos compartidosnota.txt modificado recibe otro inodo; el anterior se conserva.
La pertenencia a dos versiones no convierte un inodo en dos objetos independientes.

Una escritura sobre ese inodo cambia lo que ven ambos nombres. chmod y los tiempos del inodo también se comparten. En cambio, reemplazar solo una ruta por un archivo nuevo mediante rename permite que la otra siga apuntando al objeto viejo.

El origen nunca se enlaza directamente al repositorio: se copia. Los enlaces se crean entre objetos ya almacenados y validados de versiones del repositorio. Esto evita que editar normalmente el archivo original modifique el respaldo. La referencia link(2) describe identidad y restricciones, incluido el límite entre sistemas de archivos.

3. Repositorio, árboles y manifiestos

Distribución persistente del proyecto
copias/                         directorio propio, modo 0700
    .lock                       coordinación entre comandos cooperantes
    v1/
        manifest.json           descripción completa de esa versión
        data/
            nota.txt
            sub/datos.bin
    v2/
        manifest.json           otro archivo de manifiesto
        data/
            nota.txt            copia nueva si cambió
            sub/datos.bin       enlace a v1 si su contenido coincide
    .tmp49-.../                 construcción aún no publicada, si existe

Los identificadores de versión admiten de 1 a 40 letras, dígitos, guion y guion bajo, comenzando por letra o dígito. Ninguna versión válida comienza por punto: los temporales tienen un espacio de nombres distinto.

El manifiesto registra formato y versión, nombre del respaldo, antecesora, entradas y contadores de copia y enlace. Por cada ruta relativa registra tipo, permisos ordinarios y mtime_ns. Los archivos añaden longitud y SHA-256; los directorios, incluidos los vacíos y la raíz, también están representados.

Las rutas se validan antes de usarlas: no se aceptan absolutas, componentes de ascenso, barra inversa ni representaciones ambiguas. La raíz se representa con cadena vacía. Se admiten nombres UTF-8 y JSON escapa controles al mostrarlos.

4. Decidir qué archivo puede compartirse

Tamaño y fecha son pistas, pero no demuestran igualdad. Un archivo puede cambiar de AAAA a BBBB, conservar cuatro bytes y recuperar su mtime anterior. El laboratorio incluye precisamente ese caso.

Criterio de reutilización en la misma ruta relativa
El archivo actual es regular y estable.
La versión anterior fue verificada por completo.
Existe allí la misma ruta relativa como archivo regular.
Longitud y SHA-256 coinciden.
Una comparación binaria adicional confirma los mismos bytes.
El archivo anterior no es ya el mismo inodo que el origen.

Entonces: enlazar desde la versión anterior al nuevo árbol.
En otro caso: copiar a un inodo nuevo y verificar la copia.

Solo se comparte por la misma ruta. Un archivo renombrado recibe una copia en este algoritmo, aunque su contenido exista bajo otro nombre. No se construye un índice global de deduplicación ni se intenta preservar la topología de hard links que ya tuviera el origen.

El ahorro es de almacenamiento y de escrituras para los archivos reutilizados, no de todas las lecturas: se lee y verifica contenido. La herramienta prioriza trazabilidad para el curso sobre una exploración rápida basada exclusivamente en metadatos.

El hash del manifiesto permite detectar desacuerdos con los datos almacenados. No autentica a quien creó el manifiesto ni protege contra un actor que pueda modificar tanto el contenido como sus hashes.

5. Separar metadatos lógicos y objetos almacenados

Aplicar copystat o chmod a un archivo recién enlazado modificaría también sus otros nombres. Para evitar ese problema, los archivos de datos del repositorio tienen una representación física normalizada: modo 0444 y mtime cero. Los metadatos originales ordinarios se guardan en el manifiesto de cada versión.

Así, si solo cambia un permiso del origen, la nueva versión puede compartir los bytes y registrar un modo lógico distinto sin alterar los permisos o mtime del inodo compartido. Al restaurar, se crea una copia y se aplican sus permisos y mtime desde el manifiesto elegido.

Crear otro enlace sí cambia st_nlink y puede actualizar ctime; no se preserva el ctime original. La separación evita aplicar permisos o mtime de una versión sobre los datos compartidos. Al restaurar, el programa comprueba que el destino represente los valores solicitados y falla si no puede hacerlo exactamente.

PropiedadEn el repositorioAl restaurar
Contenido.Archivo nuevo o inodo compartido entre versiones.Copia independiente verificada.
Permisos rwx ordinarios.Valor lógico en manifiesto; datos físicos 0444.Se aplican los de esa versión.
mtime en nanosegundos.Valor lógico en manifiesto; archivo físico con mtime=0.Se aplica el valor registrado.
Propietario, ACL, xattrs y bits especiales.No se conservan en este formato.No se prometen restaurados.
Huecos y enlaces duros del origen.Se conserva contenido, no esa topología ni asignación.Archivos independientes, con los bytes esperados.

El modo 0444 reduce escrituras accidentales de usuarios ordinarios; la inmutabilidad es una convención del repositorio, no una propiedad imposible de alterar. El propietario puede cambiar permisos o reemplazar nombres. Nunca se edita una versión publicada como si fuera un directorio de trabajo.

La documentación de shutil también distingue contenido y límites de copia de metadatos. Este proyecto enumera explícitamente qué conserva en lugar de presentar una copia como equivalencia completa de cualquier sistema de archivos.

6. Construir la versión desde el estado actual

  1. Inventariar Recorrer el origen sin seguir enlaces, registrar tipos y una firma de metadatos y rechazar objetos fuera del contrato.
  2. Preparar un temporal privado Crearlo dentro del repositorio y construir en él un árbol completo data.
  3. Copiar o enlazar Para cada archivo, comprobar contenido y estabilidad; enlazar una copia anterior verificada o escribir un archivo nuevo, normalizarlo y sincronizarlo.
  4. Guardar el manifiesto Incluir también directorios vacíos y metadatos lógicos. Verificar que el árbol construido coincide con el manifiesto.
  5. Revisar y publicar Volver a inventariar el origen, sincronizar directorios y renombrar el temporal al nombre final sin reemplazar una ruta existente.

Los nombres borrados del origen no se trasladan a la versión nueva; permanecen en las antiguas. Un cambio de archivo a directorio o viceversa se representa mediante un árbol nuevo, sin transformar en el sitio los objetos de la versión anterior.

No se copian archivos especiales ni se abren FIFO, sockets o dispositivos para consumir su contenido. El proyecto rechaza los enlaces simbólicos en vez de seguirlos o prometer conservarlos de manera incompleta.

7. Origen estable y coordinación de escritores

Una lectura de árbol no es una instantánea atómica del sistema de archivos. Este proyecto exige un origen en reposo durante el respaldo. Compara inventarios antes y después y comprueba el descriptor de cada archivo durante su lectura para detectar muchas modificaciones.

Esas comprobaciones no constituyen una prueba general frente a escritores maliciosos ni garantizan consistencia de una base de datos activa. Una aplicación con estado transaccional necesita un procedimiento de captura apropiado o una instantánea coordinada antes de ejecutar el respaldo.

Origen y repositorio deben ser árboles disjuntos; se rechaza que uno contenga al otro. De lo contrario, el propio trabajo de respaldo podría entrar en el inventario que está copiando.

Los comandos cooperantes utilizan flock sobre un archivo estable .lock: creación obtiene bloqueo exclusivo y consulta o restauración, compartido. Se usa modo no bloqueante para informar competencia, en lugar de dejar un comando esperando sin explicación. Los bloqueos no impiden que un editor externo o el propietario modifique manualmente el repositorio.

8. Publicar sin sobrescribir una versión existente

El nombre público aparece al terminar de construir y comprobar el árbol. El temporal y el destino están en el mismo sistema de archivos. Se sincronizan archivos y directorios del candidato antes de publicar, y el padre después del cambio de nombre.

Protocolo de publicación de un directorio completo
crear temporal privado en el padre del destino
construir y comprobar su contenido completo
fsync de archivos y directorios necesarios
renameat2(temporal, destino, RENAME_NOREPLACE)
fsync del directorio padre

Antes de rename: el nombre final aún no existe.
Después de rename: existe la versión completa; falta confirmar el padre.

Un rename ordinario podría reemplazar un directorio vacío existente. El proyecto usa la operación Linux renameat2 con RENAME_NOREPLACE mediante ctypes y falla si esa interfaz no está disponible; no cambia silenciosamente a una comprobación previa seguida de un reemplazo.

fsync(2) distingue sincronizar un archivo de sincronizar el directorio que contiene su nombre. Si falla después del rename, el diagnóstico incluye PUBLICADO y la ruta visible, sin afirmar que la versión anterior o la ausencia del destino se hayan restaurado.

El mismo protocolo se utiliza para iniciar el repositorio y publicar una restauración. Los puntos RESPALDO49_FALLO=antes y RESPALDO49_FALLO=despues generan errores controlados alrededor de publicar; no simulan todos los efectos de un apagado.

9. Verificar y restaurar una versión independiente

La verificación valida el manifiesto antes de usar rutas, exige correspondencia exacta con el árbol, comprueba tipos y normalización física y vuelve a calcular longitud y SHA-256. Archivos adicionales, ausentes o cambiados provocan rechazo.

Restaurar crea un árbol nuevo fuera del repositorio. Cada archivo se copia y se verifica antes de aplicar sus permisos y mtime lógicos. Los directorios se ajustan al final, de abajo hacia arriba, para que sus tiempos no queden alterados por la creación posterior de hijos.

La restauración nunca enlaza los archivos de trabajo al respaldo. Editar el resultado no modifica versiones almacenadas. También se rechaza un destino existente, aunque esté vacío, en vez de fusionar contenidos y dejar un árbol cuya procedencia sea ambigua.

El programa conserva los permisos ordinarios, incluido el bit ejecutable, pero no propietario, grupo, ACL, atributos extendidos, bits especiales ni atime original. Es una restauración completa dentro de ese contrato, no una promesa sobre metadatos no registrados.

10. Interfaz, requisitos y alcance

ComandoUso
init repositorioCrear un repositorio nuevo propio, modo 0700.
crear origen repositorio nombreCrear una primera versión copiando sus archivos.
crear origen repositorio nombre --anterior baseVerificar base y compartir sus archivos sin cambios en la misma ruta.
verificar repositorio nombreComprobar manifiesto y árbol completo de esa versión.
restaurar repositorio nombre destino-nuevoPublicar una copia independiente en un destino inexistente.

Se requiere Linux con Python 3, fcntl y renameat2 disponible en libc, kernel y sistema de archivos. No se necesitan paquetes Python externos ni privilegios de administrador para un repositorio propio que admita enlaces duros y sincronización de directorios.

Se admiten hasta 10000 entradas, contando la raíz, profundidad máxima 32 y manifiesto de hasta 8 MiB. Los contenidos se procesan en tramos de 1 MiB, sin cargar cada archivo completo en memoria. mtime debe caber en el rango no negativo definido por el proyecto.

El estado 0 representa éxito del comando, 1 un error operativo o de contrato y 2 un uso rechazado por argparse. La salida se presenta como JSON para representar rutas y controles de forma explícita. No hay comando de eliminación automática ni selección implícita de una versión «más reciente».

Las rutas de directorios se consideran confiables y estables; O_NOFOLLOW protege los componentes finales que se abren. El programa no es un aislamiento frente a cambios adversarios en toda la jerarquía. El repositorio no se modifica manualmente durante una operación.

11. Implementación del respaldo en Python

Guarda respaldo49.py o descarga el programa. Python organiza el recorrido y el manifiesto; os.link, fsync, flock y renameat2 implementan las operaciones del sistema. La API os de Python documenta acceso por descriptores, enlaces y tiempos.

respaldo49.py · crear, verificar y restaurar
import argparse
from contextlib import contextmanager
import ctypes
import fcntl
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import re
import shutil
import stat
import sys
import tempfile

CHUNK = 1024 * 1024
MAX_ENTRADAS = 10000
MAX_MANIFIESTO = 8 * 1024 * 1024


class PublicadoError(RuntimeError):
    pass


def identificador(s):
    if not isinstance(s, str) or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_-]{0,39}', s):
        raise ValueError('Identificador: 1..40 letras, digitos, guion o guion bajo')
    return s


def ruta_relativa(s):
    if not isinstance(s, str) or '\x00' in s or '\\' in s:
        raise ValueError('Ruta relativa fuera del contrato')
    s.encode('utf-8')
    p = PurePosixPath(s)
    if s and (s == '.' or p.is_absolute() or p.as_posix() != s or '..' in p.parts or len(p.parts) > 32):
        raise ValueError('Ruta no canonica o demasiado profunda')
    return s


def firma(st):
    return (st.st_dev, st.st_ino, st.st_mode, st.st_size, st.st_mtime_ns, st.st_ctime_ns)


def directorio(ruta):
    p = Path(ruta).absolute()
    if not stat.S_ISDIR(p.lstat().st_mode):
        raise ValueError('Se requiere directorio real, sin enlace final')
    return p.resolve(strict=True)


def destino_nuevo(ruta):
    p = Path(ruta).absolute()
    if not p.name or p.name in ('.', '..'):
        raise ValueError('Destino invalido')
    return directorio(p.parent) / p.name


def contiene(a, b):
    return a == b or a in b.parents


def sincronizar_dir(p):
    fd = os.open(p, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC)
    try:
        os.fsync(fd)
    finally:
        os.close(fd)


def aplicar_metadatos(fd, modo, mtime_ns):
    os.fchmod(fd, modo)
    os.utime(fd, ns=(mtime_ns, mtime_ns))
    st = os.fstat(fd)
    if stat.S_IMODE(st.st_mode) != modo or st.st_mtime_ns != mtime_ns:
        raise ValueError('El destino no representa los metadatos solicitados')
    os.fsync(fd)


def renombrar_exclusivo(origen, destino):
    libc = ctypes.CDLL(None, use_errno=True)
    funcion = getattr(libc, 'renameat2', None)
    if funcion is None:
        raise RuntimeError('Esta practica Linux requiere renameat2 en libc y kernel')
    funcion.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
    funcion.restype = ctypes.c_int
    # AT_FDCWD=-100 y RENAME_NOREPLACE=1 son constantes de la ABI Linux.
    if funcion(-100, os.fsencode(origen), -100, os.fsencode(destino), 1):
        codigo = ctypes.get_errno()
        raise OSError(codigo, os.strerror(codigo), str(destino))


def publicar(temporal, destino):
    if os.environ.get('RESPALDO49_FALLO') == 'antes':
        raise RuntimeError('Fallo controlado antes de publicar')
    renombrar_exclusivo(temporal, destino)
    try:
        if os.environ.get('RESPALDO49_FALLO') == 'despues':
            raise RuntimeError('Fallo controlado despues de publicar')
        sincronizar_dir(destino.parent)
    except (OSError, RuntimeError) as e:
        raise PublicadoError(f'PUBLICADO={destino}; durabilidad no confirmada: {e}') from e


def limpiar(temporal):
    if temporal is None or not os.path.lexists(temporal):
        return
    if not temporal.name.startswith('.tmp49-') or not stat.S_ISDIR(temporal.lstat().st_mode):
        raise RuntimeError('Se rechazo limpiar una ruta que no es el temporal propio')
    os.chmod(temporal, 0o700)
    for padre, dirs, _ in os.walk(temporal, topdown=True, followlinks=False):
        for nombre in dirs:
            p = Path(padre) / nombre
            if stat.S_ISDIR(p.lstat().st_mode):
                os.chmod(p, 0o700)
    shutil.rmtree(temporal)


@contextmanager
def bloqueo(repo, exclusivo):
    st = repo.stat()
    if st.st_uid != os.geteuid() or stat.S_IMODE(st.st_mode) != 0o700:
        raise ValueError('Repositorio propio con modo 0700 requerido')
    fd = os.open(repo / '.lock', os.O_RDWR | os.O_NOFOLLOW | os.O_CLOEXEC)
    try:
        if not stat.S_ISREG(os.fstat(fd).st_mode):
            raise ValueError('El archivo de coordinacion no es regular')
        modo = fcntl.LOCK_EX if exclusivo else fcntl.LOCK_SH
        fcntl.flock(fd, modo | fcntl.LOCK_NB)
        yield
    finally:
        os.close(fd)


def explorar(raiz):
    encontrados = {}
    def visitar(p, rel):
        ruta_relativa(rel)
        st = p.lstat()
        if not (stat.S_ISDIR(st.st_mode) or stat.S_ISREG(st.st_mode)):
            raise ValueError('Solo se admiten directorios y archivos regulares: ' + repr(rel))
        if st.st_mtime_ns < 0 or st.st_mtime_ns > 2**63 - 1:
            raise ValueError('mtime fuera del rango del proyecto')
        encontrados[rel] = st
        if len(encontrados) > MAX_ENTRADAS:
            raise ValueError('Demasiadas entradas para esta practica')
        if stat.S_ISDIR(st.st_mode):
            with os.scandir(p) as it:
                nombres = sorted(e.name for e in it)
            for nombre in nombres:
                visitar(p / nombre, nombre if not rel else rel + '/' + nombre)
    visitar(raiz, '')
    return encontrados


@contextmanager
def abrir_regular(p, esperada=None):
    fd = os.open(p, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC)
    try:
        antes = os.fstat(fd)
        if not stat.S_ISREG(antes.st_mode) or (esperada is not None and firma(antes) != esperada):
            raise ValueError('Archivo inesperado o cambiado: ' + repr(str(p)))
        with os.fdopen(fd, 'rb', closefd=False) as f:
            yield f, antes
            if firma(os.fstat(fd)) != firma(antes):
                raise ValueError('El archivo cambio durante la lectura')
    finally:
        os.close(fd)


def resumen_archivo(p, esperada=None, salida=None):
    h, cantidad = hashlib.sha256(), 0
    with abrir_regular(p, esperada) as (f, st):
        while True:
            b = f.read(CHUNK)
            if not b:
                break
            h.update(b)
            cantidad += len(b)
            if salida is not None:
                salida.write(b)
        if cantidad != st.st_size:
            raise ValueError('Longitud de lectura incoherente')
    return cantidad, h.hexdigest()


def iguales(a, b):
    with abrir_regular(a) as (fa, _), abrir_regular(b) as (fb, _):
        while True:
            bloque = fa.read(CHUNK)
            if not bloque:
                return fb.read(1) == b''
            if bloque != fb.read(len(bloque)):
                return False


def sincronizar_arbol(raiz, entradas):
    carpetas = [rel for rel, st in entradas.items() if stat.S_ISDIR(st.st_mode)]
    for rel in sorted(carpetas, key=lambda s: len(PurePosixPath(s).parts), reverse=True):
        sincronizar_dir(raiz / rel)


def cargar_snapshot(p, nombre):
    if not stat.S_ISDIR(p.lstat().st_mode):
        raise ValueError('Snapshot no es un directorio real')
    if {x.name for x in p.iterdir()} != {'data', 'manifest.json'}:
        raise ValueError('Estructura de snapshot inesperada')
    with abrir_regular(p / 'manifest.json') as (f, st):
        if st.st_size > MAX_MANIFIESTO:
            raise ValueError('Manifiesto demasiado grande')
        contenido = f.read(MAX_MANIFIESTO + 1)
        if len(contenido) > MAX_MANIFIESTO:
            raise ValueError('Manifiesto crecido durante la lectura')
        m = json.loads(contenido)
    if (not isinstance(m, dict) or set(m) != {'formato', 'version', 'nombre', 'anterior', 'entradas', 'copiados', 'enlazados'} or
        m['formato'] != 'RESPALDO49' or type(m['version']) is not int or m['version'] != 1 or m['nombre'] != nombre):
        raise ValueError('Manifiesto fuera del formato admitido')
    if m['anterior'] is not None:
        identificador(m['anterior'])
    registros = m['entradas']
    if not isinstance(registros, list) or not 1 <= len(registros) <= MAX_ENTRADAS:
        raise ValueError('Lista de entradas invalida')
    indice = {}
    for e in registros:
        if not isinstance(e, dict) or e.get('tipo') not in ('dir', 'file'):
            raise ValueError('Entrada de manifiesto invalida')
        requeridos = {'ruta', 'tipo', 'modo', 'mtime_ns'}
        if e['tipo'] == 'file': requeridos |= {'bytes', 'sha256'}
        if set(e) != requeridos:
            raise ValueError('Campos de manifiesto inesperados')
        rel = ruta_relativa(e['ruta'])
        if rel in indice or type(e['modo']) is not int or not 0 <= e['modo'] <= 0o777:
            raise ValueError('Ruta duplicada o modo invalido')
        if type(e['mtime_ns']) is not int or not 0 <= e['mtime_ns'] <= 2**63 - 1:
            raise ValueError('mtime invalido')
        if e['tipo'] == 'file' and (type(e['bytes']) is not int or e['bytes'] < 0 or
                                  not isinstance(e['sha256'], str) or not re.fullmatch('[0-9a-f]{64}', e['sha256'])):
            raise ValueError('Longitud o hash invalido')
        indice[rel] = e
    if '' not in indice or indice['']['tipo'] != 'dir':
        raise ValueError('Falta la raiz del arbol')
    for rel in indice:
        if rel:
            padre = PurePosixPath(rel).parent.as_posix()
            padre = '' if padre == '.' else padre
            if padre not in indice or indice[padre]['tipo'] != 'dir':
                raise ValueError('Falta un directorio padre')
    reales = explorar(p / 'data')
    if set(reales) != set(indice):
        raise ValueError('El arbol no coincide con el manifiesto')
    for rel, st in reales.items():
        e = indice[rel]
        if stat.S_ISDIR(st.st_mode) != (e['tipo'] == 'dir'):
            raise ValueError('Tipo de entrada diferente')
        if e['tipo'] == 'file':
            if stat.S_IMODE(st.st_mode) != 0o444 or st.st_mtime_ns != 0:
                raise ValueError('Archivo almacenado fuera del modo normalizado')
            if resumen_archivo(p / 'data' / rel) != (e['bytes'], e['sha256']):
                raise ValueError('Contenido distinto del manifiesto: ' + repr(rel))
    for k in ('copiados', 'enlazados'):
        if type(m[k]) is not int or m[k] < 0:
            raise ValueError('Contadores invalidos')
    if m['copiados'] + m['enlazados'] != sum(e['tipo'] == 'file' for e in registros):
        raise ValueError('Contabilidad de archivos incoherente')
    return m, indice


def iniciar(ruta):
    destino = destino_nuevo(ruta)
    temporal = Path(tempfile.mkdtemp(prefix='.tmp49-init-', dir=destino.parent))
    try:
        os.chmod(temporal, 0o700)
        fd = os.open(temporal / '.lock', os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
        try: os.fchmod(fd, 0o600); os.fsync(fd)
        finally: os.close(fd)
        os.chmod(temporal, 0o700)
        sincronizar_dir(temporal)
        publicar(temporal, destino)
    finally:
        limpiar(temporal)
    return {'repositorio': str(destino)}


def crear(origen, repositorio, nombre, anterior=None):
    identificador(nombre)
    if anterior is not None: identificador(anterior)
    origen, repo = directorio(origen), directorio(repositorio)
    if contiene(origen, repo) or contiene(repo, origen):
        raise ValueError('Origen y repositorio deben ser arboles disjuntos')
    temporal = None
    with bloqueo(repo, True):
        if os.path.lexists(repo / nombre):
            raise FileExistsError('La version ya existe')
        previo = cargar_snapshot(repo / anterior, anterior)[1] if anterior else {}
        inicial = explorar(origen)
        temporal = Path(tempfile.mkdtemp(prefix='.tmp49-', dir=repo))
        try:
            os.chmod(temporal, 0o700)
            data = temporal / 'data'; data.mkdir(mode=0o700); os.chmod(data, 0o700)
            registros, copiados, enlazados = [], 0, 0
            for rel, st in sorted(inicial.items()):
                e = dict(ruta=rel, tipo='dir' if stat.S_ISDIR(st.st_mode) else 'file',
                         modo=stat.S_IMODE(st.st_mode) & 0o777, mtime_ns=st.st_mtime_ns)
                nuevo = data / rel
                if e['tipo'] == 'dir':
                    if rel: nuevo.mkdir(mode=0o700); os.chmod(nuevo, 0o700)
                else:
                    tamano, sha = resumen_archivo(origen / rel, firma(st))
                    e.update(bytes=tamano, sha256=sha)
                    viejo = repo / anterior / 'data' / rel if anterior else None
                    candidato = previo.get(rel)
                    compartir = (candidato is not None and candidato['tipo'] == 'file' and
                                 (candidato['bytes'], candidato['sha256']) == (tamano, sha))
                    if compartir:
                        v = viejo.stat()
                        compartir = (v.st_dev, v.st_ino) != (st.st_dev, st.st_ino) and iguales(origen / rel, viejo)
                    if compartir:
                        os.link(viejo, nuevo, follow_symlinks=False)
                        if resumen_archivo(nuevo) != (tamano, sha):
                            raise ValueError('El archivo enlazado cambio')
                        with abrir_regular(nuevo) as (f, _): os.fsync(f.fileno())
                        enlazados += 1
                    else:
                        with nuevo.open('xb') as f:
                            if resumen_archivo(origen / rel, firma(st), f) != (tamano, sha):
                                raise ValueError('El origen cambio entre las lecturas')
                            f.flush()
                            aplicar_metadatos(f.fileno(), 0o444, 0)
                        copiados += 1
                registros.append(e)
            final = explorar(origen)
            if {k: firma(v) for k, v in inicial.items()} != {k: firma(v) for k, v in final.items()}:
                raise ValueError('El origen cambio durante el respaldo')
            m = dict(formato='RESPALDO49', version=1, nombre=nombre, anterior=anterior,
                     entradas=registros, copiados=copiados, enlazados=enlazados)
            contenido = json.dumps(m, ensure_ascii=True, sort_keys=True, indent=2).encode('ascii')
            if len(contenido) > MAX_MANIFIESTO: raise ValueError('Manifiesto demasiado grande')
            with (temporal / 'manifest.json').open('xb') as f:
                f.write(contenido); f.flush(); os.fchmod(f.fileno(), 0o400); os.fsync(f.fileno())
            cargar_snapshot(temporal, nombre)
            sincronizar_arbol(data, explorar(data))
            sincronizar_dir(temporal)
            publicar(temporal, repo / nombre)
            return {'version': nombre, 'copiados': copiados, 'enlazados': enlazados}
        finally:
            limpiar(temporal)


def verificar(repositorio, nombre):
    identificador(nombre)
    repo = directorio(repositorio)
    with bloqueo(repo, False):
        m, _ = cargar_snapshot(repo / nombre, nombre)
    return {'version': nombre, 'verificado': True, 'entradas': len(m['entradas'])}


def restaurar(repositorio, nombre, ruta):
    identificador(nombre)
    repo, destino = directorio(repositorio), destino_nuevo(ruta)
    if contiene(repo, destino) or contiene(destino, repo):
        raise ValueError('Destino de restauracion fuera del repositorio requerido')
    temporal = None
    with bloqueo(repo, False):
        _, indice = cargar_snapshot(repo / nombre, nombre)
        if os.path.lexists(destino): raise FileExistsError('El destino ya existe')
        temporal = Path(tempfile.mkdtemp(prefix='.tmp49-restore-', dir=destino.parent))
        try:
            os.chmod(temporal, 0o700)
            for rel, e in sorted(indice.items()):
                p = temporal / rel
                if e['tipo'] == 'dir':
                    if rel: p.mkdir(mode=0o700); os.chmod(p, 0o700)
                else:
                    with p.open('xb') as f:
                        if resumen_archivo(repo / nombre / 'data' / rel, salida=f) != (e['bytes'], e['sha256']):
                            raise ValueError('Contenido cambiado durante restauracion')
                        f.flush(); aplicar_metadatos(f.fileno(), e['modo'], e['mtime_ns'])
            carpetas = [rel for rel, e in indice.items() if e['tipo'] == 'dir']
            for rel in sorted(carpetas, key=lambda s: len(PurePosixPath(s).parts), reverse=True):
                fd = os.open(temporal / rel, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
                try:
                    e = indice[rel]
                    aplicar_metadatos(fd, e['modo'], e['mtime_ns'])
                finally: os.close(fd)
            publicar(temporal, destino)
        finally:
            limpiar(temporal)
    return {'restaurado': nombre, 'destino': str(destino)}


def main():
    p = argparse.ArgumentParser(description='Versiones completas con enlaces duros entre archivos sin cambios')
    sub = p.add_subparsers(dest='accion', required=True)
    q = sub.add_parser('init'); q.add_argument('repositorio')
    q = sub.add_parser('crear')
    q.add_argument('origen'); q.add_argument('repositorio'); q.add_argument('nombre')
    q.add_argument('--anterior')
    q = sub.add_parser('verificar'); q.add_argument('repositorio'); q.add_argument('nombre')
    q = sub.add_parser('restaurar')
    q.add_argument('repositorio'); q.add_argument('nombre'); q.add_argument('destino')
    a = p.parse_args()
    try:
        if a.accion == 'init': r = iniciar(a.repositorio)
        elif a.accion == 'crear': r = crear(a.origen, a.repositorio, a.nombre, a.anterior)
        elif a.accion == 'verificar': r = verificar(a.repositorio, a.nombre)
        else: r = restaurar(a.repositorio, a.nombre, a.destino)
    except (OSError, ValueError, RuntimeError, RecursionError) as e:
        print(json.dumps({'error': str(e)}, ensure_ascii=True), file=sys.stderr)
        return 1
    print(json.dumps(r, ensure_ascii=True))
    return 0


if __name__ == '__main__':
    raise SystemExit(main())

La normalización solo se aplica a archivos nuevos. El camino de enlace no ejecuta chmod ni utime sobre el inodo compartido. La verificación final del candidato y el segundo inventario del origen ocurren antes de publicar.

Las limpiezas se limitan al temporal creado por esa ejecución. Si el cambio de nombre ya ocurrió, no se borra el destino publicado para intentar deshacer un resultado cuya durabilidad aún no se confirmó.

12. Programa en C: comparar identidad de inodos

Guarda identidad49.c o descarga el inspector. Consulta dos archivos regulares sin seguir el enlace final y compara la pareja st_dev,st_ino. También imprime st_nlink. Retorna 0 al consultar, 1 ante fallo y 2 por uso incorrecto; que dos archivos sean distintos no es un error de ejecución.

identidad49.c · mismo contenido no equivale necesariamente al mismo inodo
#define _POSIX_C_SOURCE 200809L
#include <sys/types.h>
#include <sys/stat.h>
#include <inttypes.h>
#include <stdio.h>

int main(int argc, char **argv)
{
    if (argc != 3) { fprintf(stderr, "Uso: %s archivoA archivoB\n", argv[0]); return 2; }
    struct stat a, b;
    if (lstat(argv[1], &a) || lstat(argv[2], &b)) { perror("lstat"); return 1; }
    if (!S_ISREG(a.st_mode) || !S_ISREG(b.st_mode)) {
        fprintf(stderr, "Se requieren dos archivos regulares, sin enlace final\n"); return 1;
    }
    printf("MISMO_INODO=%s\n", a.st_dev == b.st_dev && a.st_ino == b.st_ino ? "si" : "no");
    printf("DEV_A=%" PRIuMAX "\nDEV_B=%" PRIuMAX "\n", (uintmax_t)a.st_dev, (uintmax_t)b.st_dev);
    printf("INODO_A=%" PRIuMAX "\nINODO_B=%" PRIuMAX "\n", (uintmax_t)a.st_ino, (uintmax_t)b.st_ino);
    printf("ENLACES_A=%" PRIuMAX "\nENLACES_B=%" PRIuMAX "\n", (uintmax_t)a.st_nlink, (uintmax_t)b.st_nlink);
    if (fflush(stdout) == EOF) { perror("stdout"); return 1; }
    return 0;
}

Comparar solo st_ino no basta entre sistemas de archivos. Un contenido igual puede estar en dos inodos distintos, como ocurre al restaurar. Un inodo igual a través de dos nombres comparte también sus metadatos físicos, aunque cada versión tenga metadatos lógicos distintos en su manifiesto.

13. Laboratorio con tres versiones

En Bash de Linux crea un directorio nuevo y guarda los tres fuentes descargables del tema. El origen y el repositorio son hermanos. El ejercicio no utiliza directorios personales existentes ni modifica archivos fuera de su laboratorio.

Preparar origen y primera versión
mkdir laboratorio49
cd laboratorio49
# Guardar respaldo49.py, identidad49.c y verificar-respaldo49.py.
cc -std=c11 -O2 -Wall -Wextra -Wpedantic identidad49.c -o identidad49
python3 - <<'PY'
from pathlib import Path
import os
p = Path('origen')
p.mkdir(mode=0o750)
(p / 'sub').mkdir()
(p / 'directorio-vacio').mkdir()
for nombre, datos in [('nota.txt', b'AAAA'), ('sub/datos.bin', bytes(range(256)) * 4), ('cero', b'')]:
    with (p / nombre).open('xb') as f:
        f.write(datos)
os.chmod(p / 'cero', 0o600)
PY
python3 respaldo49.py init copias
python3 respaldo49.py crear origen copias v1
python3 respaldo49.py verificar copias v1

La primera versión informa tres archivos copiados y ninguno enlazado. El directorio vacío también figura en el manifiesto, sin contarse como archivo de datos.

Contenido cambiado con igual tamaño y fecha; metadato cambiado sin tocar bytes
python3 - <<'PY'
from pathlib import Path
import os
p = Path('origen/nota.txt')
antes = p.stat()
p.write_bytes(b'BBBB')
os.utime(p, ns=(antes.st_atime_ns, antes.st_mtime_ns))
os.chmod('origen/cero', 0o640)
with Path('origen/nuevo.txt').open('xb') as f:
    f.write(b'nuevo')
PY
python3 respaldo49.py crear origen copias v2 --anterior v1
./identidad49 copias/v1/data/sub/datos.bin copias/v2/data/sub/datos.bin
./identidad49 copias/v1/data/nota.txt copias/v2/data/nota.txt
./identidad49 origen/nota.txt copias/v2/data/nota.txt

v2 copia nota.txt y nuevo.txt y enlaza datos.bin y cero: dos copias y dos enlaces. datos.bin comparte inodo entre versiones; nota.txt modificado no. El origen y su copia almacenada tampoco comparten inodo. El modo lógico de cero cambia de 0600 a 0640, mientras su objeto almacenado sigue siendo 0444.

Borrado, renombrado y restauración de una versión anterior
python3 - <<'PY'
from pathlib import Path
Path('origen/sub/datos.bin').unlink()
Path('origen/nuevo.txt').rename('origen/renombrado.txt')
PY
python3 respaldo49.py crear origen copias v3 --anterior v2
python3 respaldo49.py verificar copias v3
python3 respaldo49.py restaurar copias v1 recuperado-v1
python3 respaldo49.py restaurar copias v2 recuperado-v2
cmp recuperado-v1/sub/datos.bin copias/v1/data/sub/datos.bin

En v3, datos.bin ya no aparece, pero permanece en v1 y v2. El renombrado se copia bajo su nueva ruta; nota y cero se enlazan: una copia y dos enlaces en esta carga. La restauración v1 recupera AAAA, datos.bin, cero con modo 0600 y el directorio vacío.

Para examinar los manifiestos puedes usar python3 -m json.tool copias/v2/manifest.json. Repetir un identificador de versión o un destino de restauración produce un error sin sobrescribirlo. No edites los archivos de data para ensayar cambios: modifica el origen o una restauración independiente.

14. Verificador de contenido, identidad y restauración

Guarda verificar-respaldo49.py o descarga las pruebas. Todo el ensayo se realiza en un directorio temporal propio. Mantiene un modelo de bytes, modos y mtime del origen por versión, y compara cada restauración con ese modelo.

verificar-respaldo49.py · cambios, vínculos, fallos y recuperación
from pathlib import Path
from tempfile import TemporaryDirectory
import fcntl
import json
import os
import shutil
import stat
import subprocess
import sys


def main():
    if len(sys.argv) not in (2, 3):
        raise SystemExit('Uso: python3 verificar-respaldo49.py respaldo49.py [./identidad49]')
    programa = str(Path(sys.argv[1]).resolve())
    inspector = str(Path(sys.argv[2]).resolve()) if len(sys.argv) == 3 else None
    entorno = dict(os.environ)
    entorno.pop('RESPALDO49_FALLO', None)
    with TemporaryDirectory(prefix='respaldo49-') as d:
        base = Path(d)
        fuente, repo = base / 'origen', base / 'repositorio'
        fuente.mkdir(mode=0o750)
        (fuente / 'sub').mkdir(mode=0o750)
        (fuente / 'vacia').mkdir(mode=0o700)
        (fuente / 'nota.txt').write_bytes(b'AAAA')
        (fuente / 'sub/datos.bin').write_bytes(bytes(range(256)) * 8193)
        (fuente / 'cero').write_bytes(b'')
        os.chmod(fuente / 'nota.txt', 0o640)
        os.chmod(fuente / 'cero', 0o600)
        fecha = 1700000000000000000
        os.utime(fuente / 'nota.txt', ns=(fecha, fecha))

        def ejecutar(*args, estado=0, fallo=None):
            env = dict(entorno)
            if fallo: env['RESPALDO49_FALLO'] = fallo
            r = subprocess.run(['python3', programa, *map(str, args)], env=env,
                               capture_output=True, text=True, timeout=30)
            assert r.returncode == estado, (args, r.returncode, r.stderr)
            return r

        def arbol(raiz):
            resultado = {}
            for p in [raiz, *sorted(raiz.rglob('*'))]:
                st = p.lstat()
                rel = '' if p == raiz else p.relative_to(raiz).as_posix()
                assert stat.S_ISDIR(st.st_mode) or stat.S_ISREG(st.st_mode)
                resultado[rel] = (None if p.is_dir() else p.read_bytes(),
                                  stat.S_IMODE(st.st_mode) & 0o777, st.st_mtime_ns)
            return resultado

        def manifiesto(nombre):
            return json.loads((repo / nombre / 'manifest.json').read_text())

        def restaurar_y_comparar(nombre, esperado, destino=None):
            destino = destino or base / ('restaurado-' + nombre)
            ejecutar('verificar', repo, nombre)
            ejecutar('restaurar', repo, nombre, destino)
            assert arbol(destino) == esperado
            for rel, (datos, _, _) in esperado.items():
                if datos is not None:
                    assert not (destino / rel).samefile(repo / nombre / 'data' / rel)
            ejecutar('restaurar', repo, nombre, destino, estado=1)
            return destino

        ejecutar('init', repo)
        assert stat.S_IMODE(repo.stat().st_mode) == 0o700
        ejecutar('init', repo, estado=1)
        modelos = {'v1': arbol(fuente)}
        r = json.loads(ejecutar('crear', fuente, repo, 'v1').stdout)
        assert (r['copiados'], r['enlazados']) == (3, 0)
        for rel in ('nota.txt', 'sub/datos.bin', 'cero'):
            assert not (fuente / rel).samefile(repo / 'v1/data' / rel)
        m1_original = (repo / 'v1/manifest.json').read_bytes()
        (fuente / 'nota.txt').write_bytes(b'BBBB')
        os.utime(fuente / 'nota.txt', ns=(fecha, fecha))  # Igual longitud y mtime, contenido diferente.
        os.chmod(fuente / 'cero', 0o640)  # Solo cambia un metadato logico.
        (fuente / 'nuevo.txt').write_bytes(b'nuevo')
        modelos['v2'] = arbol(fuente)
        r = json.loads(ejecutar('crear', fuente, repo, 'v2', '--anterior', 'v1').stdout)
        assert (r['copiados'], r['enlazados']) == (2, 2)
        assert (repo / 'v1/data/sub/datos.bin').samefile(repo / 'v2/data/sub/datos.bin')
        assert (repo / 'v1/data/cero').samefile(repo / 'v2/data/cero')
        assert not (repo / 'v1/data/nota.txt').samefile(repo / 'v2/data/nota.txt')
        assert (repo / 'v1/data/nota.txt').read_bytes() == b'AAAA'
        assert (repo / 'v1/manifest.json').read_bytes() == m1_original
        for nombre in ('v1', 'v2'):
            for p in (repo / nombre / 'data').rglob('*'):
                if p.is_file():
                    assert stat.S_IMODE(p.stat().st_mode) == 0o444 and p.stat().st_mtime_ns == 0
        m1 = {e['ruta']: e for e in manifiesto('v1')['entradas']}
        m2 = {e['ruta']: e for e in manifiesto('v2')['entradas']}
        assert m1['cero']['modo'] == 0o600 and m2['cero']['modo'] == 0o640
        (fuente / 'sub/datos.bin').unlink()
        (fuente / 'nuevo.txt').rename(fuente / 'renombrado.txt')
        (fuente / 'café\nlinea.txt').write_bytes(b'nombre con salto de linea')
        modelos['v3'] = arbol(fuente)
        r = json.loads(ejecutar('crear', fuente, repo, 'v3', '--anterior', 'v2').stdout)
        assert (r['copiados'], r['enlazados']) == (2, 2)
        assert not (repo / 'v3/data/sub/datos.bin').exists()
        assert (repo / 'v2/data/sub/datos.bin').exists()
        (fuente / 'cero').unlink()
        (fuente / 'cero').mkdir()
        (fuente / 'cero/contenido').write_bytes(b'ahora es un directorio')
        modelos['v4'] = arbol(fuente)
        ejecutar('crear', fuente, repo, 'v4', '--anterior', 'v3')
        for nombre, esperado in modelos.items():
            restaurar_y_comparar(nombre, esperado)
        (base / 'restaurado-v2/nota.txt').write_bytes(b'editar la restauracion')
        assert (repo / 'v2/data/nota.txt').read_bytes() == b'BBBB'
        ejecutar('crear', fuente, repo, 'v4', estado=1)
        ejecutar('crear', fuente, repo, '../escape', estado=1)
        (repo / 'ocupado').mkdir()
        ejecutar('crear', fuente, repo, 'ocupado', estado=1)

        ejecutar('crear', fuente, repo, 'antes', '--anterior', 'v4', estado=1, fallo='antes')
        assert not (repo / 'antes').exists() and not list(repo.glob('.tmp49-*'))
        r = ejecutar('crear', fuente, repo, 'despues', '--anterior', 'v4', estado=1, fallo='despues')
        assert 'PUBLICADO=' in r.stderr
        restaurar_y_comparar('despues', modelos['v4'])
        ejecutar('restaurar', repo, 'v1', base / 'rest-antes', estado=1, fallo='antes')
        assert not (base / 'rest-antes').exists()
        r = ejecutar('restaurar', repo, 'v1', base / 'rest-despues', estado=1, fallo='despues')
        assert 'PUBLICADO=' in r.stderr and arbol(base / 'rest-despues') == modelos['v1']

        for nombre, creador in [('enlace', lambda p: p.symlink_to(base / 'ajeno')),
                                 ('fifo', lambda p: os.mkfifo(p)),
                                 ('barra\\inversa', lambda p: p.write_bytes(b'x'))]:
            p = fuente / nombre
            creador(p)
            ejecutar('crear', fuente, repo, 'rechazado', estado=1)
            assert not (repo / 'rechazado').exists()
            p.unlink()
        assert not list(repo.glob('.tmp49-*'))
        fd = os.open(repo / '.lock', os.O_RDWR)
        try:
            fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
            ejecutar('crear', fuente, repo, 'ocupado-lock', estado=1)
            ejecutar('verificar', repo, 'v1', estado=1)
        finally: os.close(fd)
        ejecutar('verificar', repo, 'v1')

        for nombre, tipo in [('corrupta', 'contenido'), ('manifiesto-malo', 'ruta')]:
            shutil.copytree(repo / 'v1', repo / nombre)
            m = manifiesto(nombre); m['nombre'] = nombre
            if tipo == 'contenido':
                p = repo / nombre / 'data/nota.txt'
                assert not p.samefile(repo / 'v1/data/nota.txt')
                os.chmod(p, 0o600); p.write_bytes(b'ZZZZ')
                os.chmod(p, 0o444); os.utime(p, ns=(0, 0))
            else:
                next(e for e in m['entradas'] if e['tipo'] == 'file')['ruta'] = '../escape'
            p = repo / nombre / 'manifest.json'
            os.chmod(p, 0o600); p.write_text(json.dumps(m)); os.chmod(p, 0o400)
            ejecutar('verificar', repo, nombre, estado=1)
            ejecutar('restaurar', repo, nombre, base / ('salida-' + nombre), estado=1)
            assert not (base / ('salida-' + nombre)).exists()
        ejecutar('crear', fuente, repo, 'base-invalida', '--anterior', 'corrupta', estado=1)
        assert not (repo / 'base-invalida').exists()
        ejecutar('init', fuente / 'repo-interno')
        ejecutar('crear', fuente, fuente / 'repo-interno', 'prohibido', estado=1)
        ejecutar('restaurar', repo, 'v1', repo / 'dentro', estado=1)
        ejecutar('verificar', repo, 'v1')
        if inspector:
            a = repo / 'v1/data/sub/datos.bin'; b = repo / 'v2/data/sub/datos.bin'
            r = subprocess.run([inspector, str(a), str(b)], capture_output=True, text=True, check=True)
            assert 'MISMO_INODO=si' in r.stdout
            r = subprocess.run([inspector, str(repo / 'v1/data/nota.txt'), str(repo / 'v2/data/nota.txt')],
                               capture_output=True, text=True, check=True)
            assert 'MISMO_INODO=no' in r.stdout
        compartido = repo / 'v2/data/sub/datos.bin'
        enlaces_antes = compartido.stat().st_nlink
        fd = os.open(repo / '.lock', os.O_RDWR)
        try:
            fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
            shutil.rmtree(repo / 'v1')  # Solo una version de este directorio temporal propio.
        finally: os.close(fd)
        assert compartido.stat().st_nlink == enlaces_antes - 1
        restaurar_y_comparar('v2', modelos['v2'], base / 'sin-ancestro')
        print('OK: versiones, enlaces, cambios de igual tamano/fecha, metadatos y restauracion')
        print('OK: borrados, tipos, publicacion, bloqueo, corrupcion y rutas rechazadas')


if __name__ == '__main__':
    main()
Ejecutar el conjunto
python3 verificar-respaldo49.py respaldo49.py ./identidad49
# Si no se compilo el inspector, las pruebas Python siguen disponibles:
python3 verificar-respaldo49.py respaldo49.py

Se verifican archivos vacíos y mayores que un tramo de lectura, cambio con igual tamaño/mtime, metadatos lógicos diferentes sobre bytes compartidos, nombres con controles representados en JSON, borrados y cambio de archivo a directorio.

Las pruebas también rechazan versiones y destinos existentes, tipos no soportados, base corrupta, rutas de manifiesto inválidas y competencia por el bloqueo. Las copias que se corrompen para probar rechazo son archivos independientes: nunca se altera mediante un enlace el objeto compartido de las versiones válidas.

El ensayo final retira una versión antigua propia, bajo el bloqueo de coordinación, y comprueba que la siguiente se verifica y restaura sin ella. La herramienta principal no ejecuta retención automática.

15. Retención, fallos y límites de la protección

Retirar un nombre de archivo de una versión reduce las referencias al inodo compartido. Mientras existan otros enlaces —o referencias abiertas que lo mantengan vivo—, el objeto no desaparece simplemente por borrar ese nombre. Una versión conservada tiene su árbol completo.

La capacidad debe evaluarse con cuidado: sumar st_size de todos los nombres cuenta varias veces contenido compartido; contar inodos distintos mide otra cosa, y ninguno equivale automáticamente a bloques físicos ocupados, especialmente con huecos, compresión o datos residentes.

SituaciónResultado del proyectoAcción de interpretación
Error antes de publicar.El nombre nuevo no existe; el temporal propio se limpia en el camino controlado.No tratar un temporal como versión completa.
Error después de publicar.Destino completo visible; durabilidad del padre no confirmada.Leer PUBLICADO y verificar antes de repetir.
Origen cambia durante la captura.Las comprobaciones pueden detectar y rechazar el candidato.Preparar un origen en reposo; no afirmar captura atómica de un árbol activo.
Corrupción de un inodo compartido.Puede afectar a varias versiones que comparten ese objeto.Verificar contenido y conservar protección independiente.

El nombre incremental no implica independencia física entre versiones. Este repositorio en el mismo almacenamiento no protege por sí solo frente a pérdida del medio ni sustituye una copia independiente. El manifiesto permite detectar contenido diferente; no lo reconstruye.

Los bloqueos del programa coordinan sus propios comandos. Una política real de retención debería usar la misma coordinación, seleccionar versiones explícitamente y registrar qué elimina. Este tema evita agregar un comando destructivo general a la práctica principal.

16. Simuladores de versiones y modificación compartida

Versiones completas, archivos compartidos y retención

Modelo de tres archivos iniciales: nota.txt de 4 bytes, datos.bin de 1024 y cero de 0. Cada versión contiene el árbol actual y solo reutiliza inodos de la misma ruta de la antecesora inmediata. Los identificadores son ficticios y los bytes contados son contenido lógico, sin directorios ni manifiestos.

Cambios e inodos por versión

VersiónRutaInodo almacenadoReferencias del modeloBytesModo lógicoDecisión al crear
Contenido acumulado y objetos distintos conservados

Escribir un inodo o reemplazar un nombre

Modelo genérico de dos enlaces duros, con modo inicial 0644 para representar una escritura autorizada. En el repositorio real los datos se normalizan a 0444 y no se editan. El ejemplo separa el efecto de escribir, reemplazar una ruta y cambiar permisos de un inodo.

La versión no divide un inodo compartido

RutaDispositivo e inodoContenidoModoEnlaces del modelo
Qué nombres comparten todavía el objeto

17. Diagnóstico y resumen

ObservaciónQué revisar
Una edición del origen cambia un respaldo.Buscar un enlace indebido entre origen y repositorio; el proyecto copia siempre desde el origen.
chmod sobre una versión afecta a otra.Ambas rutas pueden compartir el inodo; los metadatos físicos se comparten.
Tamaño y fecha coinciden, pero los bytes cambiaron.No usar esos campos como prueba suficiente de contenido.
Un archivo borrado del origen reaparece en la versión nueva.Construir desde el árbol actual, no arrastrar sin revisar la lista de nombres anterior.
La suma de tamaños parece mayor que el espacio usado.Puede contarse repetidamente un mismo inodo o intervenir otras reglas de asignación.
El comando informa error y la versión final existe.Puede haber fallado la confirmación del directorio después de publicar.
Un destino restaurado cambia versiones almacenadas.Restaurar mediante copias, nunca enlaces al repositorio.

18. Preguntas de repaso y práctica

1. ¿Cada versión necesita aplicar una cadena de diferencias?

No. Contiene el árbol completo y su propio manifiesto.

2. ¿Qué identifica al mismo inodo entre dos rutas?

La pareja st_dev y st_ino, no solo el número de inodo aislado.

3. ¿Por qué no enlazar directamente desde el origen?

Porque una escritura sobre el inodo original también modificaría el respaldo.

4. ¿Tamaño y mtime prueban contenido igual?

No. El archivo puede cambiar de bytes manteniendo ambos valores.

5. ¿Cuándo reutiliza un archivo este proyecto?

Cuando la misma ruta de la base verificada coincide en longitud, hash y comparación binaria, y no es ya el inodo del origen.

6. ¿Un renombrado se deduplica globalmente?

No. La nueva ruta se copia porque el algoritmo solo compara la misma ruta anterior.

7. ¿Dónde viven los permisos originales de cada versión?

En su manifiesto. Los objetos almacenados tienen modo físico normalizado 0444.

8. ¿Por qué no usar chmod sobre un archivo recién enlazado?

Porque cambiaría el modo del inodo que siguen usando otros nombres.

9. ¿Qué hace restaurar con los archivos?

Los copia, verifica y aplica permisos y mtime lógicos a inodos nuevos.

10. ¿Se conservan ACL, propietario y xattrs?

No en el formato del proyecto; se registran contenido, tipo, permisos ordinarios y mtime.

11. ¿Qué pasa con un archivo borrado del origen?

No aparece en la versión nueva; puede continuar en versiones anteriores.

12. ¿Un enlace simbólico se sigue automáticamente?

No. El proyecto lo rechaza como tipo fuera del contrato.

13. ¿Por qué separar origen y repositorio?

Para no incorporar el propio trabajo de respaldo al árbol que se está capturando.

14. ¿Dos inventarios hacen atómica la captura?

No. Detectan muchos cambios, pero el origen debe permanecer en reposo o capturarse por otro mecanismo coordinado.

15. ¿Qué evita RENAME_NOREPLACE?

Publicar sustituyendo una ruta final ya existente, incluso un directorio vacío.

16. ¿Error después de publicar significa versión ausente?

No. Puede estar completa y visible aunque la durabilidad del padre no haya sido confirmada.

17. ¿Retirar v1 impide restaurar v2?

No bajo este formato: v2 conserva sus nombres, inodos enlazados y manifiesto completo.

18. ¿st_nlink mide bytes físicos ahorrados?

No. Cuenta enlaces del inodo; el espacio ocupado requiere otras medidas.

19. ¿Qué protege el modo 0444?

Reduce escrituras accidentales ordinarias, pero el propietario todavía puede alterar permisos o nombres.

20. ¿Compartir versiones protege contra perder el disco?

No. Se necesita protección independiente del medio y de los objetos compartidos.

Para practicar

  1. Relaciona dos nombres compartidos con st_dev, st_ino y st_nlink.
  2. Reproduce el cambio de AAAA a BBBB conservando tamaño y mtime y contrasta la decisión de copia.
  3. Cambia solo permisos del origen y compara los dos manifiestos y el objeto compartido.
  4. Restaura dos versiones y confirma que editar una restauración no altera el repositorio.
  5. Elimina un archivo del origen y verifica su ausencia nueva y presencia histórica.
  6. Renombra un archivo y explica por qué este algoritmo lo copia.
  7. Ejecuta las pruebas y distingue verificación de contenido, identidad y metadatos.
  8. Observa los fallos antes y después de publicar y decide qué consultar antes de repetir.
  9. En el simulador, retira la versión más antigua y contrasta referencias y contenido distinto conservado.
  10. Describe una política de retención que coordine con .lock y evite tocar versiones en construcción.
  11. Enumera qué faltaría para conservar enlaces simbólicos, ACL o la topología de hard links del origen.
  12. Diseña una prueba periódica de restauración y una copia independiente del repositorio.

En el tema 50 cerraremos el curso: relacionaremos las operaciones, formatos y garantías estudiados y prepararemos el puente a Comunicación entre Procesos.