Skip to content

Latest commit

 

History

History
378 lines (244 loc) · 28.2 KB

File metadata and controls

378 lines (244 loc) · 28.2 KB

File Integrity Monitor — Explicação Detalhada do Código

Este documento explica cada decisão técnica do projeto FIM, em duas camadas:

  • 🔧 Técnico — o que está acontecendo de fato, em termos de linguagem/CS.
  • 🌱 Iniciante — a mesma ideia, com analogia ou explicação mais simples.

A ideia é você entender não só o que o código faz, mas por que foi feito assim — e conseguir justificar essas escolhas numa entrevista ou code review.


1. hasher.py — o coração do FIM

1.1 Por que hash, e por que SHA-256?

import hashlib

🔧 Técnico: Um hash criptográfico transforma qualquer conteúdo (1 byte ou 1 GB) numa string de tamanho fixo (SHA-256 sempre gera 64 caracteres hexadecimais). A propriedade que importa aqui é o efeito avalanche: mudar 1 bit no arquivo muda completamente o hash resultante. Isso torna inviável prever ou forjar um hash que "engane" o sistema para achar que dois conteúdos diferentes são iguais (resistência a colisão).

🌱 Iniciante: Pensa no hash como uma "impressão digital" do arquivo. Duas pessoas nunca têm a mesma impressão digital — e se um arquivo muda um único caractere, a impressão digital dele muda inteira. Isso é o que permite ao FIM saber "esse arquivo é exatamente o mesmo de antes?" sem precisar guardar o arquivo inteiro, só a impressão dele.

Por que SHA-256 e não MD5 ou SHA-1? MD5 e SHA-1 são considerados quebrados para fins de segurança — já existem técnicas conhecidas para gerar colisões (dois arquivos diferentes com o mesmo hash), o que anularia o propósito de um FIM: um atacante poderia alterar um arquivo malicioso e "camuflar" o hash para bater com o original. SHA-256 (família SHA-2) ainda não tem colisão prática conhecida e é o padrão de mercado para integridade de arquivos (é o que o Git usa internamente para objetos, por exemplo — bom, o Git usa SHA-1 legado, mas está migrando para SHA-256 exatamente por isso).


1.2 Por que ler em chunks (pedaços) em vez do arquivo inteiro?

CHUNK_SIZE = 65536  # 64 KB

with open(file_path, "rb") as f:
    while chunk := f.read(CHUNK_SIZE):
        hasher.update(chunk)

🔧 Técnico: Se fizéssemos f.read() sem argumento, o Python carregaria o arquivo inteiro na RAM antes de calcular o hash. Para um arquivo de 500 MB, isso significa 500 MB de RAM só para esse hash — e se você estiver hasheando centenas de arquivos, o consumo de memória explode. Lendo em blocos de 64 KB e alimentando o hasher incrementalmente via .update(), a memória usada fica praticamente constante, independente do tamanho do arquivo. Isso é possível porque hashes criptográficos são construídos sobre funções de compressão iterativas (Merkle–Damgård, no caso do SHA-2) — o algoritmo processa bloco por bloco internamente de qualquer forma.

🌱 Iniciante: Imagina que você precisa "resumir" um livro gigante. Em vez de tentar segurar o livro inteiro aberto na mão de uma vez, você lê capítulo por capítulo, vai anotando um resumo acumulado, e no final tem o resumo completo — sem nunca precisar segurar o livro inteiro aberto. É exatamente isso que o while chunk := f.read(...) faz: lê um pedacinho, processa, descarta, lê o próximo.

O operador := (walrus operator): Introduzido no Python 3.8, ele permite atribuir e testar uma variável na mesma linha. while chunk := f.read(CHUNK_SIZE): lê um chunk, guarda em chunk, e o while continua enquanto esse chunk não for "falsy" (ou seja, não for b"", que é o que read() retorna ao chegar no fim do arquivo). Sem o walrus, precisaríamos de um loop mais verboso:

chunk = f.read(CHUNK_SIZE)
while chunk:
    hasher.update(chunk)
    chunk = f.read(CHUNK_SIZE)

O walrus evita repetir a chamada f.read() duas vezes — menos código, menos chance de bug por esquecer de atualizar uma das duas.

Por que 64 KB especificamente? É um valor comum na indústria (é, por exemplo, o tamanho de buffer default em muitas implementações de hashing). É grande o suficiente para não gerar overhead de milhares de chamadas de I/O em arquivos grandes, e pequeno o suficiente para não pesar na memória. Não é um número mágico — dava pra usar 4 KB ou 1 MB — mas 64 KB é um bom meio-termo testado.


1.3 hashlib.new(algorithm) em vez de hashlib.sha256()

hasher = hashlib.new(algorithm)

🔧 Técnico: hashlib.new("sha256") é equivalente a hashlib.sha256(), mas permite que o algoritmo seja um parâmetro em vez de estar hardcoded. Isso é o princípio de injeção de dependência aplicado num nível simples: a função não decide qual algoritmo usar, quem chama ela decide. Se amanhã você quiser oferecer sha512 ou blake2b como opção, não precisa mexer na lógica de hashing — só passar outro valor de string.

🌱 Iniciante: É a diferença entre escrever "sempre vou de carro" versus "vou pelo meio de transporte que me passarem". O código fica flexível sem precisar ser reescrito.

Trade-off importante: essa flexibilidade tem um custo — hashlib.new() aceita qualquer string, incluindo algoritmos fracos como "md5". Numa versão mais robusta, seria interessante validar contra uma lista de algoritmos permitidos (allowlist) para não permitir configuração insegura por engano. Fica registrado como possível melhoria.


1.4 Por que Path em vez de strings puras?

from pathlib import Path

def compute_file_hash(file_path: Path, algorithm: str = "sha256") -> str:

🔧 Técnico: pathlib.Path (desde Python 3.4) é a forma moderna de trabalhar com caminhos de arquivo, substituindo o antigo os.path. Ele resolve dois problemas: (1) diferenças entre sistemas operacionais — Path sabe usar \ no Windows e / no Linux/Mac automaticamente; (2) legibilidade — operações como file_path.exists(), file_path.is_dir(), file_path.relative_to(...) são métodos do próprio objeto, em vez de funções soltas como os.path.exists(file_path).

🌱 Iniciante: É a diferença entre tratar um caminho de arquivo como um texto qualquer (string) versus tratar como um objeto que sabe coisas sobre si mesmo — tipo, um Path sabe responder "eu existo?", "eu sou uma pasta?", sem você precisar chamar uma função externa pra descobrir isso.


1.5 Type hints (-> str, : Path, dict[str, str])

🔧 Técnico: Type hints não mudam o comportamento em runtime (Python continua duck-typed), mas são lidos por ferramentas como mypy, pyright, e pela sua IDE, para pegar erros antes de rodar o código — por exemplo, se você tentar passar uma str onde a função espera um Path. Em dict[str, str], a sintaxe moderna (Python 3.9+) dispensa o from typing import Dict.

🌱 Iniciante: É como colocar uma etiqueta em cada gaveta dizendo o que pode entrar ali. Não impede fisicamente você de colocar a coisa errada, mas avisa antes de você "abrir a gaveta na hora errada" (rodar o programa e quebrar).

Por que isso importa pra você agora: em projetos de portfólio, type hints sinalizam maturidade — mostra que você pensa em manutenibilidade, não só em "fazer funcionar".


1.6 compute_hashes_for_directory — varredura recursiva

for path in sorted(directory.rglob("*")):

🔧 Técnico: rglob("*") ("recursive glob") varre o diretório e todos os subdiretórios, retornando todo arquivo e pasta. Envolvemos em sorted() por um motivo específico: rglob não garante ordem consistente entre execuções (depende do sistema de arquivos). Se a ordem mudasse a cada scan, seria mais difícil depurar e, mais importante, o dicionário de hashes ficaria "desordenado" de forma não determinística — o que dificultaria comparar dois baselines manualmente (ex: usando git diff no JSON).

🌱 Iniciante: Imagina vasculhar uma casa procurando objetos: rglob("*") é entrar em todo cômodo e todo armário, não só a sala. E sorted() é como organizar a lista de objetos encontrados em ordem alfabética, pra sempre que você fizer essa busca de novo, a lista sair na mesma ordem — facilita comparar "o que mudou".

Por que relative_path = path.relative_to(directory)? Se guardássemos o caminho absoluto (/home/ewerson/projeto/arquivo.txt), o baseline ficaria "preso" àquela máquina/pasta específica. Guardando o caminho relativo (arquivo.txt ou src/main.py), o baseline pode, teoricamente, ser movido ou comparado em outro ambiente sem quebrar.


1.7 Tratamento de erros: por que except (PermissionError, OSError): continue?

try:
    hashes[str(relative_path)] = compute_file_hash(path, algorithm)
except (PermissionError, OSError):
    continue

🔧 Técnico: Numa varredura de diretório real, é comum encontrar arquivos que o processo não tem permissão de ler (ex: arquivos de sistema, sockets, locks de outro processo). Se não tratássemos isso, uma única falha derrubaria o scan inteiro — o que é um comportamento ruim para uma ferramenta de segurança (você quer saber sobre os 999 arquivos que deram certo, não perder tudo por causa de 1 que falhou). Capturamos especificamente PermissionError e OSError (que cobre erros de I/O em geral) — não um except Exception genérico, porque isso esconderia bugs reais do seu próprio código.

🌱 Iniciante: É a diferença entre "se uma coisa der errado, eu paro tudo e desisto" versus "eu anoto que essa deu errado, mas continuo checando o resto". Para um vigia noturno checando 500 portas, não faz sentido ele desistir da ronda inteira porque uma porta estava emperrada — ele anota e segue.

Ponto de atenção que deixei comentado no código: silenciar o erro com continue esconde a falha do usuário. Numa versão mais madura, isso deveria ser logado como warning (logger.warning("Não foi possível ler %s: %s", path, e)) — porque um arquivo que "sumiu da varredura" por erro de permissão é, ele mesmo, uma informação de segurança relevante (por que esse arquivo ficou inacessível de repente?).


1.8 A função _is_ignored e o prefixo _

def _is_ignored(relative_path: Path, patterns: list[str]) -> bool:

🔧 Técnico: O _ no início do nome é uma convenção (não uma regra imposta pelo interpretador, como seria em outras linguagens) que sinaliza "isso é interno ao módulo, não faz parte da API pública". Quem importa fim.hasher não deveria chamar _is_ignored diretamente — é um detalhe de implementação que pode mudar sem aviso.

🌱 Iniciante: É tipo um bilhete escrito "uso interno" numa gaveta da cozinha de um restaurante — o cliente (quem usa o módulo de fora) não devia mexer ali, só a equipe (o próprio módulo).

Por que match() OU in?

return any(relative_path.match(pattern) or pattern in path_str for pattern in patterns)

Path.match() entende padrões glob de verdade (*.log, **/*.py). O pattern in path_str é um fallback mais "bruto" — string contains — pra cobrir casos como .git/* que às vezes não bate exatamente com match() dependendo de como o glob é interpretado. É uma solução pragmática, não elegante: funciona pra a maioria dos casos comuns, mas merece testes específicos se o projeto crescer (é um ponto que eu, honestamente, simplifiquei — merece revisão futura).


2. baseline.py — a "foto de referência"

2.1 Por que JSON e não banco de dados?

import json

🔧 Técnico: Para o escopo atual (um baseline por vez, leitura/escrita simples), JSON é suficiente e traz vantagens específicas: é legível por humano (você pode abrir e ler o baseline num editor de texto), é versionável em Git (dá pra ver o diff de um commit pro outro), e não exige nenhuma dependência externa (json é biblioteca padrão do Python). Um banco relacional (SQLite) só se justificaria se: (a) o volume de arquivos fosse muito grande (dezenas de milhares) e a busca por chave precisasse ser mais eficiente que um dict em memória; ou (b) você quisesse manter histórico de múltiplos baselines ao longo do tempo, com queries por data.

🌱 Iniciante: JSON aqui é tipo escrever a lista de compras num papel simples — dá pra ler, entender, e comparar com a lista da semana passada facilmente. Um banco de dados seria como montar uma planilha com sistema de busca — útil se a lista tivesse 10 mil itens, mas exagero pra uma lista de compras do dia a dia.

Isso já está registrado no roadmap do README como possível evolução (SQLite) — a decisão de começar com JSON foi deliberada, não por desconhecimento, mas por adequação ao tamanho atual do problema. Esse tipo de justificativa ("escolhi o simples porque resolve o problema atual, e documentei quando trocaria") é exatamente o que se espera de decisão de engenharia madura.


2.2 Por que salvar metadados (created_at, algorithm, ignore_patterns) dentro do próprio baseline?

baseline = {
    "created_at": datetime.now(timezone.utc).isoformat(),
    "target_dir": str(target_dir.resolve()),
    "algorithm": algorithm,
    "ignore_patterns": ignore_patterns or [],
    "files": hashes,
}

🔧 Técnico: Isso é o princípio de auto-descrição (self-describing data). Se o baseline guardasse só os hashes, no futuro (dias, semanas depois) você não saberia: com qual algoritmo esses hashes foram gerados? Quais padrões foram ignorados? Quando isso foi criado? Ao guardar isso junto, o scan_directory() consegue reconstruir o mesmo contexto exato usado na criação — é isso que permite ao scanner.py pegar algorithm e ignore_patterns de dentro do próprio baseline, em vez de exigir que o usuário informe de novo toda vez que rodar scan.

🌱 Iniciante: Imagina tirar uma foto de referência de um cômodo pra depois comparar "mudou algo?". Se você só guardasse a foto, sem anotar a data e as condições (luz ligada? câmera em que ângulo?), ficaria difícil comparar de forma justa depois. Guardar os metadados junto é como escrever atrás da foto: "tirada em 10/08, luz da sala ligada, câmera na estante".

Por que timezone.utc? Guardar hora local (datetime.now() sem timezone) é uma armadilha clássica: se você mover o projeto pra um servidor em outro fuso, ou se o sistema mudar de horário de verão, os timestamps ficam ambíguos ou incomparáveis. UTC é o padrão universal — sem ambiguidade, sempre comparável.


2.3 Por que target_dir.resolve()?

"target_dir": str(target_dir.resolve()),

🔧 Técnico: .resolve() converte um caminho relativo (../projeto ou ./src) em um caminho absoluto e canônico, resolvendo .., . e links simbólicos. Isso importa porque, se você rodar fim init de dentro de uma pasta e depois fim scan de outra pasta, um caminho relativo salvo no baseline apontaria pro lugar errado. Salvando o caminho absoluto resolvido, o scan sempre encontra o diretório certo, não importa de onde for chamado.

🌱 Iniciante: É a diferença entre dar um endereço tipo "duas quadras depois daquele mercado" (relativo — só funciona se você já estiver perto) versus "Rua X, número Y, cidade Z" (absoluto — funciona de qualquer lugar).


2.4 save_baseline e load_baseline como funções separadas

🔧 Técnico: Isso é aplicação do Princípio da Responsabilidade Única (o "S" do SOLID): create_baseline decide o que vai no baseline (a lógica de negócio: escanear, montar o dict); save_baseline decide como persistir (a lógica de I/O: escrever em JSON). Essa separação significa que se amanhã você quiser trocar o formato de armazenamento (JSON → SQLite → banco remoto), só precisa reescrever save_baseline/load_baseline — create_baseline nem percebe a mudança, porque ele só monta o dicionário e delega a persistência.

🌱 Iniciante: É como separar "decidir o que vai na mala" de "decidir se a mala vai de carro ou avião". São decisões independentes — trocar o meio de transporte não muda o que você decidiu levar.


2.5 ensure_ascii=False no json.dump

json.dump(baseline, f, indent=2, ensure_ascii=False)

🔧 Técnico: Por padrão, json.dump escapa qualquer caractere não-ASCII em sequências \uXXXX. Como nomes de arquivo podem conter acentos (comum em ambiente brasileiro: relatório.txt, José.py), deixar ensure_ascii=False mantém o JSON legível com os caracteres originais, em vez de virar relat\u00f3rio.txt.

🌱 Iniciante: Sem essa opção, "relatório.txt" apareceria no arquivo salvo como um código estranho em vez do nome normal — funciona tecnicamente, mas fica ilegível pra um humano abrir e entender.


2.6 FileNotFoundError customizado com mensagem de ajuda

if not baseline_file.exists():
    raise FileNotFoundError(
        f"No baseline found at '{baseline_file}'. Run 'fim init' first."
    )

🔧 Técnico: Em vez de deixar o erro genérico do Python estourar (que seria só [Errno 2] No such file or directory), capturamos a condição antes e levantamos o mesmo tipo de exceção, mas com uma mensagem que já diz o que o usuário deve fazer para resolver. Isso é importante numa ferramenta de linha de comando: o usuário final não vai ler o seu código-fonte pra entender o erro — a mensagem de erro é a documentação naquele momento.

🌱 Iniciante: É a diferença entre um erro que diz só "deu erro" (você fica sem saber o que fazer) e um erro que diz "não achei o baseline, roda o comando X primeiro" (você já sabe o próximo passo).


3. scanner.py — comparando o "antes" e o "agora"

3.1 Por que @dataclass em vez de um dicionário simples?

from dataclasses import dataclass, field

@dataclass
class ScanResult:
    modified: list[str] = field(default_factory=list)
    added: list[str] = field(default_factory=list)
    deleted: list[str] = field(default_factory=list)

🔧 Técnico: @dataclass (Python 3.7+) gera automaticamente __init__, __repr__ e __eq__ a partir dos campos declarados — evita boilerplate. Usar uma classe estruturada em vez de {"modified": [...], "added": [...], "deleted": [...]} traz autocompletar na IDE (result.modified, não result["modificad"] com erro de digitação silencioso), e permite adicionar comportamento junto aos dados — como as @property abaixo.

🌱 Iniciante: Um dicionário é uma caixa onde você tem que lembrar exatamente o nome de cada etiqueta pra achar a coisa (e se errar o nome, o Python só reclama na hora de rodar). Uma dataclass é como uma ficha de formulário pré-impressa, com campos fixos — o editor de código já sabe quais campos existem e te ajuda a preenchê-los certo, sem você digitar errado.

Por que field(default_factory=list) e não = [] direto? Essa é uma pegadinha clássica do Python: se você escrever modified: list[str] = [] diretamente numa dataclass (ou como argumento padrão de função), todas as instâncias da classe compartilhariam a mesma lista — porque o valor padrão é avaliado uma única vez, na definição da classe, não a cada nova instância. default_factory=list diz "toda vez que criar uma instância nova, chame list() de novo" — garantindo que cada ScanResult tenha sua própria lista independente.


3.2 As @property (has_changes, total_changes)

@property
def has_changes(self) -> bool:
    return bool(self.modified or self.added or self.deleted)

🔧 Técnico: @property permite chamar result.has_changes (sem parênteses, como se fosse um atributo) mesmo sendo, por trás, um método calculado. A vantagem sobre guardar has_changes como um campo normal é que ele nunca fica desatualizado — é sempre recalculado na hora que você acessa, então não existe risco de esquecer de atualizar esse valor depois de popular modified/added/deleted.

🌱 Iniciante: É a diferença entre ter uma placa fixa escrita "tem gente em casa: sim" (que alguém precisa lembrar de trocar toda vez que sai ou entra) e ter um sensor que checa na hora se tem luz acesa (sempre certo, calculado no momento que você olha).


3.3 A lógica de comparação em si

for path, current_hash in current_hashes.items():
    if path not in known_hashes:
        result.added.append(path)
    elif known_hashes[path] != current_hash:
        result.modified.append(path)

for path in known_hashes:
    if path not in current_hashes:
        result.deleted.append(path)

🔧 Técnico: Note que são dois loops separados, não um só — e isso é intencional. O primeiro loop percorre o estado atual e descobre o que é novo (added) ou diferente (modified) em relação ao baseline. O segundo loop percorre o baseline e descobre o que existia antes mas sumiu agora (deleted). Não dá pra descobrir "deletado" no primeiro loop, porque ele só itera sobre arquivos que existem agora — um arquivo deletado, por definição, não vai aparecer nessa iteração. Essa é a razão estrutural de precisar dos dois loops: conjuntos diferentes (current_hashes.keys() vs known_hashes.keys()) precisam ser percorridos separadamente para cobrir os três casos.

🌱 Iniciante: Imagina comparar duas listas de presença — a de ontem e a de hoje. Pra achar quem é novo hoje, você olha a lista de hoje e vê quem não tava ontem. Pra achar quem faltou hoje, você precisa olhar a lista de ontem e ver quem não apareceu na de hoje. São duas perguntas diferentes, então precisam de duas checagens diferentes — não dá pra responder as duas olhando só uma das listas.

Complexidade: usar path not in known_hashes é uma checagem em dicionário (hash lookup), que é O(1) em média — não O(n) como seria numa lista. É por isso que os hashes são guardados como dict[str, str] (chave = caminho, valor = hash) em vez de uma lista de tuplas: permite essas checagens de pertencimento serem rápidas mesmo com milhares de arquivos.


4. cli.py — a porta de entrada

4.1 Por que argparse com subcomandos, em vez de sys.argv cru?

subparsers = parser.add_subparsers(dest="command", required=True)
init_parser = subparsers.add_parser("init", ...)
scan_parser = subparsers.add_parser("scan", ...)

🔧 Técnico: argparse é biblioteca padrão do Python (sem dependência extra) e já resolve, de graça: --help automático, mensagens de erro formatadas quando o usuário digita algo errado, validação de tipos e obrigatoriedade de argumentos, e subcomandos (fim init ... vs fim scan ...) — que é o mesmo padrão usado por ferramentas como git, docker, npm. Fazer isso na mão com sys.argv[1], sys.argv[2] seria reinventar tudo isso, com muito mais chance de bug.

🌱 Iniciante: É a diferença entre montar você mesmo um formulário de atendimento do zero (com toda a validação de campo obrigatório, mensagens de erro, etc.) ou usar um formulário pronto que já sabe fazer tudo isso — você só define os campos.


4.2 set_defaults(func=cmd_init) — o padrão de despacho por função

init_parser.set_defaults(func=cmd_init)
scan_parser.set_defaults(func=cmd_init)  # (na prática, cmd_scan)
...
def main() -> int:
    args = parser.parse_args()
    return args.func(args)

🔧 Técnico: Esse é o padrão "command dispatch". Em vez de escrever um bloco if args.command == "init": cmd_init(args) elif args.command == "scan": cmd_scan(args) (que cresce e fica feio conforme você adiciona comandos), cada subparser já "sabe" qual função deve rodar, e main() só chama args.func(args) — sem precisar saber qual comando foi escolhido. Isso deixa main() extremamente simples e escalável: adicionar um comando novo (fim watch, por exemplo) não exige tocar em main() — só adicionar um novo subparsers.add_parser(...) com seu set_defaults.

🌱 Iniciante: É como uma recepção de hospital que, em vez de o recepcionista decidir manualmente "ah, esse é problema de olho, vou mandar pro oftalmologista" (um monte de "se for isso, faça aquilo"), cada ficha de paciente já vem com um adesivo dizendo direto pra qual consultório ir. A recepção só olha o adesivo e encaminha — não precisa saber a árvore de decisão toda.


4.3 Exit codes diferentes (0, 1, 2)

return 0   # sucesso, sem mudanças
return 1   # erro (ex: baseline não existe)
return 2   # mudanças detectadas

🔧 Técnico: Isso segue a convenção Unix de exit codes: 0 sempre significa sucesso; qualquer valor diferente de zero significa "algo a reportar". Ao diferenciar 1 (erro genuíno) de 2 (execução correta, mas com achado relevante), a ferramenta se torna scriptável — dá pra usar em cron job ou pipeline de CI assim:

python -m fim.cli scan
if [ $? -eq 2 ]; then
    echo "Mudanças detectadas, enviando alerta..."
fi

Sem essa distinção, um script não teria como diferenciar automaticamente "o comando falhou" de "o comando rodou certo e encontrou algo suspeito".

🌱 Iniciante: É como o semáforo de um processo: verde (0) = tudo certo, passa; amarelo (2) = rodou certo, mas achou algo que merece atenção; vermelho (1) = o processo em si quebrou. Um programa automatizado (tipo um robô que monitora seu servidor de madrugada) consegue reagir diferente pra cada cor, sem um humano precisar ler o log na hora.


4.4 Logging em vez de print()

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    ...
)
logger = logging.getLogger("fim")

🔧 Técnico: logging oferece coisas que print() não dá de graça: níveis de severidade (INFO, WARNING, ERROR) que permitem filtrar o que é mostrado sem mudar o código-fonte; timestamp automático em cada linha; e a possibilidade de, no futuro, redirecionar essas mensagens para um arquivo, um serviço de monitoramento (ex: Sentry, Datadog) ou um webhook — sem tocar em nenhuma linha de lógica de negócio, só na configuração do logging. Isso é essencial numa ferramenta de segurança: você quer rastro (audit trail) do que rodou, quando, e o que foi encontrado — não só uma mensagem que aparece e desaparece do terminal.

🌱 Iniciante: print() é como gritar uma informação numa sala vazia — some assim que você sai. logging é como escrever num diário com data e hora — fica registrado, e você pode decidir depois quem lê esse diário (a tela, um arquivo, um sistema de alerta).


5. Padrões de projeto usados no geral (visão consolidada)

Padrão / Princípio Onde aparece Por quê
Separação de responsabilidades (SRP) hasher / baseline / scanner / cli em módulos distintos Cada módulo tem um único motivo pra mudar. Mudar o formato de storage não afeta o hashing.
Injeção de configuração algorithm e ignore_patterns como parâmetros, não hardcoded Permite reuso e testes sem editar código-fonte.
Dados auto-descritivos Baseline guarda seus próprios metadados O scan não depende de o usuário lembrar como o baseline foi gerado.
Fail gracefully (falhar com elegância) try/except no scan de diretório Um arquivo problemático não derruba a varredura inteira.
Command dispatch cli.py com set_defaults(func=...) Escalável: novos comandos não exigem editar main().
Exit codes semânticos 0 / 1 / 2 Torna a ferramenta integrável em scripts e automações.

6. Perguntas para você testar seu próprio entendimento

Se quiser confirmar que entendeu (recomendo tentar responder antes de me perguntar):

  1. Por que trocar SHA-256 por MD5 no create_baseline seria uma falha de segurança, mesmo que o programa continuasse funcionando normalmente?
  2. Se você remover o sorted() do rglob("*"), o programa quebra? O que muda de fato?
  3. Por que scan_directory não recebe ignore_patterns como argumento, se create_baseline recebe?
  4. O que aconteceria se ScanResult usasse modified: list[str] = [] em vez de field(default_factory=list) — em que cenário isso causaria um bug visível?

Se quiser, posso revisar suas respostas, ou já seguimos pra próxima etapa do roadmap (watch com watchdog, ou testes com pytest cobrindo esses módulos).