Quando seu app não consegue retirar uma permissão que já concedeu
Encontrei um bug de permissão no Greenhouse essa semana que não tem uma correção limpa. Não “limpa” no sentido de “eu não me esforcei o suficiente” - limpa no sentido de que a API subjacente genuinamente não suporta desfazer o que ela permite fazer, e a correção é uma troca de UX, não um truque de código.
O problema
Greenhouse é um app desktop (Tauri v2 + Rust + Svelte) onde o usuário escolhe uma pasta no disco, seu “vault”, e o app gerencia projetos dentro dela. Para pré-visualizar arquivos de áudio no app, o webview precisa de acesso de leitura àquela pasta. A resposta do Tauri para “deixe o webview ler este diretório específico” é o scope em tempo de execução do asset protocol: chame allow_directory(path, recursive) assim que souber o caminho, e convertFileSrc passa a funcionar para qualquer coisa dentro dele.
Greenhouse também permite trocar de vault sem reiniciar, apontando o app para uma pasta diferente no meio da sessão. Deveria ser fácil: conceder scope para a nova pasta, pronto.
Só que fui procurar a outra metade dessa API, algo como forbid_directory ou revoke, para que trocar de vault também retirasse o acesso de leitura a ele. Não existe. Li o código-fonte vendorizado do Tauri para ter certeza: o scope é sustentado por um allow-set e um forbid-set, e allow_directory só insere no allow-set. Nada remove dele, durante toda a vida do processo.
Por que essa abordagem
Meu primeiro instinto foi forbid_directory(old_path) na troca, combater fogo com fogo. Isso tem sua própria armadilha: padrões proibidos têm precedência sobre os permitidos na checagem is_allowed do Tauri, e também não existe um “un-forbid”. Proíba o vault A uma vez, e voltar para ele mais tarde na mesma sessão fica permanentemente quebrado, você teria que reiniciar de qualquer jeito, só que num momento pior (quando o usuário está tentando voltar para um vault em que já confia).
Então as opções reais eram: aceitar que todo vault visitado continua legível pelo webview durante a sessão e documentar isso, conceder acesso arquivo por arquivo em vez de diretório por diretório (muito mais superfície de IPC para pouquíssimo benefício num app local de usuário único), ou fazer a troca de vault só valer a partir de um restart, em vez de ao vivo. Fui com restart-para-trocar. Um processo novo começa com zero concessões de scope, esse é o único momento em que o problema de acúmulo simplesmente não existe, porque nada foi concedido ainda.
Implementação
O comando que define a raiz do vault agora captura qual era a raiz anterior antes de fazer qualquer outra coisa, e compara com a nova. Se forem iguais (configuração pela primeira vez, ou escolher o vault em que você já está), ele ativa imediatamente como antes. Se forem diferentes, é uma troca de verdade: o novo caminho é persistido nas configurações para que o próximo lançamento o pegue, mas a concessão de scope de fato e a ativação em memória são puladas por completo. O comando retorna se um restart é necessário, e o frontend mostra um painel “reinicie para trocar” em vez de simplesmente fechar o diálogo.
O restart em si é uma linha, relaunch() do tauri-plugin-process, protegido por uma capability restrita a essa única permissão, não a capability padrão mais ampla que também concede exit.
Pegadinhas
Verificar isso sem um humano clicando foi um quebra-cabeça à parte. O fluxo de troca começa com um seletor de pasta nativo do sistema operacional, que um WebDriver literalmente não consegue tocar, não existe um DOM para encontrar. E o comando de restart, assim que dispara, mata exatamente o processo ao qual sua sessão WebDriver está conectada, então não existe uma resposta de “deu certo” para ler; o processo que a enviaria já se foi.
O truque que funcionou: invocar o comando de restart diretamente via IPC (contornando o seletor ao chamar set_vault_root com um caminho fixo), e depois ler a forma da falha. Um erro no nível de conexão, o próprio fetch falhando, não um corpo de erro em JSON, significa que o comando executou e derrubou o processo junto com ele. Uma resposta JSON de verdade reclamando de permissões significaria que a capability estava mal configurada. Confirmei a parte do “realmente reiniciou” de forma independente com um simples ps aux procurando um PID novo. Isso me deu confiança real na fiação (plugin registrado, capability conectada, comando alcançável) sem nunca precisar do diálogo nativo no loop para essa parte.
O que eu não consegui automatizar, e nem tentei: se o app realmente sobe no vault novo depois de um restart de verdade disparado pela interface de verdade. Esse foi o único ponto em que parei de automatizar e cliquei manualmente em vez de caçar um jeito de simular.
Resultados
Trocar de vault agora mostra um aviso claro de “precisa reiniciar” em vez de trocar silenciosamente ao vivo e vazar acesso de leitura para todo vault que você já abriu naquela sessão. É uma pequena regressão de UX, trocar costumava ser instantâneo, em troca de uma propriedade de segurança que realmente se sustenta. Vale a pena para o que é, supostamente, um app local de usuário único? Discutível. Mas depois de ler o código-fonte e confirmar que não havia revoke, “aceitar e documentar para sempre” pareceu pior do que “custar um restart”.
Leitura relacionada
Três portas que nunca foram trancadas
Um CSP nulo, um caminho de config sem checagem, e um seletor de vault que aceitaria seu diretório home - nada quebrado ainda, tudo destrancado.
O scan que se ofereceu para importar a si mesmo
Um scanner de importação encontrou a própria estrutura interna do app, e voltou a oferecer uma pasta já importada - a mesma conversa faltando entre camadas.
O callback que não conseguia dizer o que aconteceu
Corrigir um bug de atualização do dashboard expôs um segundo escondido no mesmo componente compartilhado, e um terceiro bug que no fim não existia.
Você também pode achar útil
Proton Mail
E-mail criptografado de ponta a ponta, com arquitetura de acesso zero.
Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).
Saiba maisAdGuard para iOS
Bloqueio de anúncios e rastreadores em todo o sistema no iOS, sem necessidade de um servidor DNS separado.
Como afiliado da AdGuard, ganho com compras qualificadas.
Saiba maisProton Drive
Armazenamento em nuvem criptografado, da equipe por trás do Proton Mail.
Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).
Saiba mais