Merge remote-tracking branch 'origin/master' into ndom91/create-gitbutler-ui-package

This commit is contained in:
Caleb Owens 2024-07-02 15:31:00 +02:00
commit ac4f3b926c
No known key found for this signature in database
48 changed files with 2009 additions and 867 deletions

1450
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -11,12 +11,14 @@ members = [
resolver = "2"
[workspace.dependencies]
gix = { version = "0.63.0", default-features = false, features = [] } # add performance features here as needed
# Add the `tracing` or `tracing-detail` features to see more of gitoxide in the logs. Useful to see which programs it invokes.
gix = { git = "https://github.com/Byron/gitoxide", rev = "55cbc1b9d6f210298a86502a7f20f9736c7e963e", default-features = false, features = [] }
git2 = { version = "0.18.3", features = ["vendored-openssl", "vendored-libgit2"] }
uuid = { version = "1.8.0", features = ["serde"] }
serde = { version = "1.0", features = ["derive"] }
thiserror = "1.0.61"
tokio = { version = "1.38.0", default-features = false }
keyring = "2.3.3"
gitbutler-git = { path = "crates/gitbutler-git" }
gitbutler-core = { path = "crates/gitbutler-core" }

View File

@ -49,7 +49,8 @@
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/kit": "^2.5.10",
"@sveltejs/vite-plugin-svelte": "^3.1.1",
"@tauri-apps/api": "^1.5.5",
"@tauri-apps/api": "^1.6.0",
"@types/crypto-js": "^4.2.2",
"@types/diff": "^5.2.1",
"@types/diff-match-patch": "^1.0.36",
"@types/git-url-parse": "^9.0.3",

View File

@ -2,7 +2,7 @@ import { AnthropicAIClient } from '$lib/ai/anthropicClient';
import { ButlerAIClient } from '$lib/ai/butlerClient';
import { OpenAIClient } from '$lib/ai/openAIClient';
import { SHORT_DEFAULT_BRANCH_TEMPLATE, SHORT_DEFAULT_COMMIT_TEMPLATE } from '$lib/ai/prompts';
import { AIService, GitAIConfigKey, KeyOption, buildDiff } from '$lib/ai/service';
import { AISecretHandle, AIService, GitAIConfigKey, KeyOption, buildDiff } from '$lib/ai/service';
import {
AnthropicModelName,
ModelKind,
@ -16,17 +16,21 @@ import { Hunk } from '$lib/vbranches/types';
import { plainToInstance } from 'class-transformer';
import { expect, test, describe, vi } from 'vitest';
import type { GbConfig, GitConfigService } from '$lib/backend/gitConfigService';
import type { SecretsService } from '$lib/secrets/secretsService';
const defaultGitConfig = Object.freeze({
[GitAIConfigKey.ModelProvider]: ModelKind.OpenAI,
[GitAIConfigKey.OpenAIKeyOption]: KeyOption.ButlerAPI,
[GitAIConfigKey.OpenAIKey]: undefined,
[GitAIConfigKey.OpenAIModelName]: OpenAIModelName.GPT35Turbo,
[GitAIConfigKey.AnthropicKeyOption]: KeyOption.ButlerAPI,
[GitAIConfigKey.AnthropicKey]: undefined,
[GitAIConfigKey.AnthropicModelName]: AnthropicModelName.Haiku
});
const defaultSecretsConfig = Object.freeze({
[AISecretHandle.AnthropicKey]: undefined,
[AISecretHandle.OpenAIKey]: undefined
});
class DummyGitConfigService implements GitConfigService {
constructor(private config: { [index: string]: string | undefined }) {}
async getGbConfig(_projectId: string): Promise<GbConfig> {
@ -46,6 +50,24 @@ class DummyGitConfigService implements GitConfigService {
async set<T extends string>(key: string, value: T): Promise<T | undefined> {
return (this.config[key] = value);
}
async remove(key: string): Promise<undefined> {
delete this.config[key];
}
}
class DummySecretsService implements SecretsService {
private config: { [index: string]: string | undefined };
constructor(config?: { [index: string]: string | undefined }) {
this.config = config || {};
}
async get(key: string): Promise<string | undefined> {
return this.config[key];
}
async set(handle: string, secret: string): Promise<void> {
this.config[handle] = secret;
}
}
const fetchMock = vi.fn();
@ -108,7 +130,8 @@ const exampleHunks = [hunk1, hunk2];
function buildDefaultAIService() {
const gitConfig = new DummyGitConfigService(structuredClone(defaultGitConfig));
return new AIService(gitConfig, cloud);
const secretsService = new DummySecretsService(structuredClone(defaultSecretsConfig));
return new AIService(gitConfig, secretsService, cloud);
}
describe.concurrent('AIService', () => {
@ -130,10 +153,10 @@ describe.concurrent('AIService', () => {
test('When token is bring your own, When a openAI token is present. It returns OpenAIClient', async () => {
const gitConfig = new DummyGitConfigService({
...defaultGitConfig,
[GitAIConfigKey.OpenAIKeyOption]: KeyOption.BringYourOwn,
[GitAIConfigKey.OpenAIKey]: 'sk-asdfasdf'
[GitAIConfigKey.OpenAIKeyOption]: KeyOption.BringYourOwn
});
const aiService = new AIService(gitConfig, cloud);
const secretsService = new DummySecretsService({ [AISecretHandle.OpenAIKey]: 'sk-asdfasdf' });
const aiService = new AIService(gitConfig, secretsService, cloud);
expect(unwrap(await aiService.buildClient())).toBeInstanceOf(OpenAIClient);
});
@ -141,10 +164,10 @@ describe.concurrent('AIService', () => {
test('When token is bring your own, When a openAI token is blank. It returns undefined', async () => {
const gitConfig = new DummyGitConfigService({
...defaultGitConfig,
[GitAIConfigKey.OpenAIKeyOption]: KeyOption.BringYourOwn,
[GitAIConfigKey.OpenAIKey]: undefined
[GitAIConfigKey.OpenAIKeyOption]: KeyOption.BringYourOwn
});
const aiService = new AIService(gitConfig, cloud);
const secretsService = new DummySecretsService();
const aiService = new AIService(gitConfig, secretsService, cloud);
expect(await aiService.buildClient()).toStrictEqual(
buildFailureFromAny(
@ -157,10 +180,12 @@ describe.concurrent('AIService', () => {
const gitConfig = new DummyGitConfigService({
...defaultGitConfig,
[GitAIConfigKey.ModelProvider]: ModelKind.Anthropic,
[GitAIConfigKey.AnthropicKeyOption]: KeyOption.BringYourOwn,
[GitAIConfigKey.AnthropicKey]: 'sk-ant-api03-asdfasdf'
[GitAIConfigKey.AnthropicKeyOption]: KeyOption.BringYourOwn
});
const aiService = new AIService(gitConfig, cloud);
const secretsService = new DummySecretsService({
[AISecretHandle.AnthropicKey]: 'test-key'
});
const aiService = new AIService(gitConfig, secretsService, cloud);
expect(unwrap(await aiService.buildClient())).toBeInstanceOf(AnthropicAIClient);
});
@ -169,10 +194,10 @@ describe.concurrent('AIService', () => {
const gitConfig = new DummyGitConfigService({
...defaultGitConfig,
[GitAIConfigKey.ModelProvider]: ModelKind.Anthropic,
[GitAIConfigKey.AnthropicKeyOption]: KeyOption.BringYourOwn,
[GitAIConfigKey.AnthropicKey]: undefined
[GitAIConfigKey.AnthropicKeyOption]: KeyOption.BringYourOwn
});
const aiService = new AIService(gitConfig, cloud);
const secretsService = new DummySecretsService();
const aiService = new AIService(gitConfig, secretsService, cloud);
expect(await aiService.buildClient()).toStrictEqual(
buildFailureFromAny(

View File

@ -19,6 +19,7 @@ import { splitMessage } from '$lib/utils/commitMessage';
import OpenAI from 'openai';
import type { GitConfigService } from '$lib/backend/gitConfigService';
import type { HttpClient } from '$lib/backend/httpClient';
import type { SecretsService } from '$lib/secrets/secretsService';
import type { Hunk } from '$lib/vbranches/types';
const maxDiffLengthLimitForAPI = 5000;
@ -28,14 +29,17 @@ export enum KeyOption {
ButlerAPI = 'butlerAPI'
}
export enum AISecretHandle {
OpenAIKey = 'aiOpenAIKey',
AnthropicKey = 'aiAnthropicKey'
}
export enum GitAIConfigKey {
ModelProvider = 'gitbutler.aiModelProvider',
OpenAIKeyOption = 'gitbutler.aiOpenAIKeyOption',
OpenAIModelName = 'gitbutler.aiOpenAIModelName',
OpenAIKey = 'gitbutler.aiOpenAIKey',
AnthropicKeyOption = 'gitbutler.aiAnthropicKeyOption',
AnthropicModelName = 'gitbutler.aiAnthropicModelName',
AnthropicKey = 'gitbutler.aiAnthropicKey',
DiffLengthLimit = 'gitbutler.diffLengthLimit',
OllamaEndpoint = 'gitbutler.aiOllamaEndpoint',
OllamaModelName = 'gitbutler.aiOllamaModelName'
@ -72,6 +76,7 @@ function shuffle<T>(items: T[]): T[] {
export class AIService {
constructor(
private gitConfig: GitConfigService,
private secretsService: SecretsService,
private cloud: HttpClient
) {}
@ -90,7 +95,7 @@ export class AIService {
}
async getOpenAIKey() {
return await this.gitConfig.get(GitAIConfigKey.OpenAIKey);
return await this.secretsService.get(AISecretHandle.OpenAIKey);
}
async getOpenAIModleName() {
@ -108,7 +113,7 @@ export class AIService {
}
async getAnthropicKey() {
return await this.gitConfig.get(GitAIConfigKey.AnthropicKey);
return await this.secretsService.get(AISecretHandle.AnthropicKey);
}
async getAnthropicModelName() {

View File

@ -5,6 +5,10 @@ export class GitConfigService {
return (await invoke<T | undefined>('git_get_global_config', { key })) || undefined;
}
async remove(key: string): Promise<undefined> {
return await invoke('git_remove_global_config', { key });
}
async getWithDefault<T extends string>(key: string, defaultValue: T): Promise<T> {
const value = await invoke<T | undefined>('git_get_global_config', { key });
return value || defaultValue;

View File

@ -0,0 +1,55 @@
import { AISecretHandle } from '$lib/ai/service';
import { invoke } from '$lib/backend/ipc';
import { buildContext } from '$lib/utils/context';
import type { GitConfigService } from '$lib/backend/gitConfigService';
const MIGRATION_HANDLES = [
AISecretHandle.AnthropicKey.toString(),
AISecretHandle.OpenAIKey.toString()
];
export type SecretsService = {
get(handle: string): Promise<string | undefined>;
set(handle: string, secret: string): Promise<void>;
};
export const [getSecretsService, setSecretsService] =
buildContext<SecretsService>('secretsService');
export class RustSecretService implements SecretsService {
constructor(private gitConfigService: GitConfigService) {}
async get(handle: string) {
const secret = await invoke<string>('secret_get_global', { handle });
if (secret) return secret;
if (MIGRATION_HANDLES.includes(handle)) {
const key = 'gitbutler.' + handle;
const migratedSecret = await this.migrate(key, handle);
if (migratedSecret !== undefined) return migratedSecret;
}
}
async set(handle: string, secret: string) {
await invoke('secret_set_global', {
handle,
secret
});
}
/**
* Migrates a specific key from git config to secrets.
*
* TODO: Remove this code and the dependency on GitConfigService in the future.
*/
private async migrate(key: string, handle: string): Promise<string | undefined> {
const secretInConfig = await this.gitConfigService.get(key);
if (secretInConfig === undefined) return;
await this.set(handle, secretInConfig);
await this.gitConfigService.remove(key);
console.warn(`Migrated Git config "${key}" to secret store.`);
return secretInConfig;
}
}

View File

@ -100,3 +100,15 @@ export function getContextStoreBySymbol<T, S extends Readable<T> = Readable<T>>(
if (!instance) throw new Error(`no instance of \`Readable<${key.toString()}[]>\` in context`);
return instance;
}
export function buildContext<T>(name: string): [() => T, (value: T | undefined) => void] {
const identifier = Symbol(name);
return [
() => {
return svelteGetContext<T>(identifier);
},
(value: T | undefined) => {
setContext(identifier, value);
}
];
}

View File

@ -16,6 +16,7 @@
import { GitHubService } from '$lib/github/service';
import ToastController from '$lib/notifications/ToastController.svelte';
import { RemotesService } from '$lib/remotes/service';
import { setSecretsService } from '$lib/secrets/secretsService';
import { SETTINGS, loadUserSettings } from '$lib/settings/userSettings';
import { User, UserService } from '$lib/stores/user';
import * as events from '$lib/utils/events';
@ -36,6 +37,7 @@
setContext(SETTINGS, userSettings);
// Setters do not need to be reactive since `data` never updates
setSecretsService(data.secretsService);
setContext(UserService, data.userService);
setContext(ProjectService, data.projectService);
setContext(UpdaterService, data.updaterService);

View File

@ -9,6 +9,7 @@ import { PromptService } from '$lib/backend/prompt';
import { UpdaterService } from '$lib/backend/updater';
import { GitHubService } from '$lib/github/service';
import { RemotesService } from '$lib/remotes/service';
import { RustSecretService } from '$lib/secrets/secretsService';
import { UserService } from '$lib/stores/user';
import { mockTauri } from '$lib/testing/index';
import { LineManagerFactory } from '@gitbutler/ui/CommitLines/lineManager';
@ -54,7 +55,8 @@ export async function load() {
const githubService = new GitHubService(userService.accessToken$, remoteUrl$);
const gitConfig = new GitConfigService();
const aiService = new AIService(gitConfig, httpClient);
const secretsService = new RustSecretService(gitConfig);
const aiService = new AIService(gitConfig, secretsService, httpClient);
const remotesService = new RemotesService();
const aiPromptService = new AIPromptService();
const lineManagerFactory = new LineManagerFactory();
@ -73,6 +75,7 @@ export async function load() {
aiService,
remotesService,
aiPromptService,
lineManagerFactory
lineManagerFactory,
secretsService
};
}

View File

@ -1,10 +1,11 @@
<script lang="ts">
import { AIService, GitAIConfigKey, KeyOption } from '$lib/ai/service';
import { AISecretHandle, AIService, GitAIConfigKey, KeyOption } from '$lib/ai/service';
import { OpenAIModelName, AnthropicModelName, ModelKind } from '$lib/ai/types';
import { GitConfigService } from '$lib/backend/gitConfigService';
import AiPromptEdit from '$lib/components/AIPromptEdit/AIPromptEdit.svelte';
import SectionCard from '$lib/components/SectionCard.svelte';
import WelcomeSigninAction from '$lib/components/WelcomeSigninAction.svelte';
import { getSecretsService } from '$lib/secrets/secretsService';
import ContentWrapper from '$lib/settings/ContentWrapper.svelte';
import Section from '$lib/settings/Section.svelte';
import InfoMessage from '$lib/shared/InfoMessage.svelte';
@ -18,6 +19,7 @@
import { onMount, tick } from 'svelte';
const gitConfigService = getContext(GitConfigService);
const secretsService = getSecretsService();
const aiService = getContext(AIService);
const userService = getContext(UserService);
const user = userService.user;
@ -34,22 +36,26 @@
let ollamaEndpoint: string | undefined;
let ollamaModel: string | undefined;
function setConfiguration(key: GitAIConfigKey, value: string | undefined) {
async function setConfiguration(key: GitAIConfigKey, value: string | undefined) {
if (!initialized) return;
gitConfigService.set(key, value || '');
}
async function setSecret(handle: AISecretHandle, secret: string | undefined) {
if (!initialized) return;
await secretsService.set(handle, secret || '');
}
$: setConfiguration(GitAIConfigKey.ModelProvider, modelKind);
$: setConfiguration(GitAIConfigKey.OpenAIKeyOption, openAIKeyOption);
$: setConfiguration(GitAIConfigKey.OpenAIModelName, openAIModelName);
$: setConfiguration(GitAIConfigKey.OpenAIKey, openAIKey);
$: setSecret(AISecretHandle.OpenAIKey, openAIKey);
$: setConfiguration(GitAIConfigKey.AnthropicKeyOption, anthropicKeyOption);
$: setConfiguration(GitAIConfigKey.AnthropicModelName, anthropicModelName);
$: setConfiguration(GitAIConfigKey.AnthropicKey, anthropicKey);
$: setConfiguration(GitAIConfigKey.DiffLengthLimit, diffLengthLimit?.toString());
$: setSecret(AISecretHandle.AnthropicKey, anthropicKey);
$: setConfiguration(GitAIConfigKey.OllamaEndpoint, ollamaEndpoint);
$: setConfiguration(GitAIConfigKey.OllamaModelName, ollamaModel);

View File

@ -5,6 +5,9 @@ edition = "2021"
authors = ["GitButler <gitbutler@gitbutler.com>"]
publish = false
[[test]]
name = "secret"
path = "tests/secret/mod.rs"
[dev-dependencies]
once_cell = "1.19"
@ -12,6 +15,7 @@ pretty_assertions = "1.4"
gitbutler-testsupport.workspace = true
gitbutler-git = { workspace = true, features = ["test-askpass-path"] }
glob = "0.3.1"
serial_test = "3.1.1"
[dependencies]
toml = "0.8.13"
@ -26,8 +30,9 @@ fslock = "0.2.1"
futures = "0.3"
git2.workspace = true
git2-hooks = "0.3"
gix = { workspace = true, features = ["dirwalk"] }
gix = { workspace = true, features = ["dirwalk", "credentials", "parallel"] }
itertools = "0.13"
keyring.workspace = true
lazy_static = "1.4.0"
md5 = "0.7.0"
hex = "0.4.3"
@ -46,7 +51,7 @@ tempfile = "3.10"
thiserror.workspace = true
tokio = { workspace = true, features = [ "rt-multi-thread", "rt", "macros" ] }
tracing = "0.1.40"
url = { version = "2.5", features = ["serde"] }
url = { version = "2.5.2", features = ["serde"] }
urlencoding = "2.1.3"
uuid.workspace = true
walkdir = "2.5.0"

View File

@ -27,20 +27,17 @@ impl Proxy {
}
}
pub async fn proxy_user(&self, user: users::User) -> users::User {
match Url::parse(&user.picture) {
Ok(picture) => users::User {
picture: self.proxy(&picture).await.map_or_else(
|error| {
tracing::error!(?error, "failed to proxy user picture");
user.picture.clone()
},
|url| url.to_string(),
),
..user
},
Err(_) => user,
pub async fn proxy_user(&self, mut user: users::User) -> users::User {
if let Ok(picture) = Url::parse(&user.picture) {
user.picture = self.proxy(&picture).await.map_or_else(
|error| {
tracing::error!(?error, "failed to proxy user picture");
user.picture.clone()
},
|url| url.to_string(),
);
}
user
}
async fn proxy_virtual_branch_commit(

View File

@ -410,10 +410,10 @@ fn reverse_patch(patch: &BStr) -> Option<BString> {
return None;
}
} else if line.starts_with(b"+") {
reversed.push_str(&line.replacen(b"+", b"-", 1));
reversed.push_str(line.replacen(b"+", b"-", 1));
reversed.push(b'\n');
} else if line.starts_with(b"-") {
reversed.push_str(&line.replacen(b"-", b"+", 1));
reversed.push_str(line.replacen(b"-", b"+", 1));
reversed.push(b'\n');
} else {
reversed.push_str(line);

View File

@ -29,6 +29,8 @@ pub mod project_repository;
pub mod projects;
pub mod rebase;
pub mod remotes;
pub mod secret;
pub mod serde;
pub mod ssh;
pub mod storage;
pub mod synchronize;
@ -40,99 +42,3 @@ pub mod virtual_branches;
pub mod windows;
pub mod writer;
pub mod zip;
pub mod serde {
use crate::virtual_branches::branch::HunkHash;
use bstr::{BString, ByteSlice};
use serde::Serialize;
pub fn as_string_lossy<S>(v: &BString, s: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
v.to_str_lossy().serialize(s)
}
pub fn hash_to_hex<S>(v: &HunkHash, s: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
format!("{v:x}").serialize(s)
}
pub fn as_time_seconds_from_unix_epoch<S>(v: &git2::Time, s: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
v.seconds().serialize(s)
}
pub mod oid_opt {
use serde::{Deserialize, Deserializer, Serialize};
pub fn serialize<S>(v: &Option<git2::Oid>, s: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
v.as_ref().map(|v| v.to_string()).serialize(s)
}
pub fn deserialize<'de, D>(d: D) -> Result<Option<git2::Oid>, D::Error>
where
D: Deserializer<'de>,
{
let hex = <Option<String> as Deserialize>::deserialize(d)?;
hex.map(|v| {
v.parse()
.map_err(|err: git2::Error| serde::de::Error::custom(err.to_string()))
})
.transpose()
}
}
pub mod oid_vec {
use serde::{Deserialize, Deserializer, Serialize};
pub fn serialize<S>(v: &[git2::Oid], s: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let vec: Vec<String> = v.iter().map(|v| v.to_string()).collect();
vec.serialize(s)
}
pub fn deserialize<'de, D>(d: D) -> Result<Vec<git2::Oid>, D::Error>
where
D: Deserializer<'de>,
{
let hex = <Vec<String> as Deserialize>::deserialize(d)?;
let hex: Result<Vec<git2::Oid>, D::Error> = hex
.into_iter()
.map(|v| {
git2::Oid::from_str(v.as_str())
.map_err(|err: git2::Error| serde::de::Error::custom(err.to_string()))
})
.collect();
hex
}
}
pub mod oid {
use serde::{Deserialize, Deserializer, Serialize};
pub fn serialize<S>(v: &git2::Oid, s: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
v.to_string().serialize(s)
}
pub fn deserialize<'de, D>(d: D) -> Result<git2::Oid, D::Error>
where
D: Deserializer<'de>,
{
let hex = String::deserialize(d)?;
hex.parse()
.map_err(|err: git2::Error| serde::de::Error::custom(err.to_string()))
}
}
}

View File

@ -107,7 +107,7 @@ impl Project {
let commit_tree_id = commit_tree_builder.write()?;
commits_tree_builder.insert(
&commit_id.to_string(),
commit_id.to_string(),
commit_tree_id,
FileMode::Tree.into(),
)?;

View File

@ -378,10 +378,10 @@ impl Repository {
"pushing code to gb repo",
);
let access_token = user
.map(|user| user.access_token.clone())
.context("access token is missing")
let user = user
.context("need user to push to gitbutler")
.context(Code::ProjectGitAuth)?;
let access_token = user.access_token()?;
let mut callbacks = git2::RemoteCallbacks::new();
if self.project.omit_certificate_check.unwrap_or(false) {

View File

@ -0,0 +1,237 @@
//! This module contains facilities to handle the persistence of secrets.
//!
//! These are stateless and global, while discouraging storing secrets
//! in memory beyond their use.
use crate::types::Sensitive;
use anyhow::Result;
use std::sync::Mutex;
/// Determines how a secret's name should be modified to produce a namespace.
///
/// Namespaces can be used to partition secrets, depending on some criteria.
#[derive(Debug, Clone, Copy)]
pub enum Namespace {
/// Each application build, like `dev`, `production` and `nightly` have their
/// own set of secrets. They do not overlap, which reflects how data-files
/// are stored as well.
BuildKind,
/// All secrets are in a single namespace. There is no partitioning.
/// This can be useful for secrets to be shared across all build kinds.
Global,
}
/// Persist `secret` in `namespace` so that it can be retrieved by the given `handle`.
pub fn persist(handle: &str, secret: &Sensitive<String>, namespace: Namespace) -> Result<()> {
let entry = entry_for(handle, namespace)?;
if secret.0.is_empty() {
entry.delete_password()?;
} else {
entry.set_password(&secret.0)?;
}
Ok(())
}
/// Obtain the previously [stored](persist()) secret known as `handle` from `namespace`.
pub fn retrieve(handle: &str, namespace: Namespace) -> Result<Option<Sensitive<String>>> {
match entry_for(handle, namespace)?.get_password() {
Ok(secret) => Ok(Some(Sensitive(secret))),
Err(keyring::Error::NoEntry) => Ok(None),
Err(err) => Err(err.into()),
}
}
/// Delete the secret at `handle` permanently from `namespace`.
pub fn delete(handle: &str, namespace: Namespace) -> Result<()> {
Ok(entry_for(handle, namespace)?.delete_password()?)
}
/// Use this `identifier` as 'namespace' for identifying secrets.
/// Each namespace has its own set of secrets, useful for different application versions.
///
/// Note that the namespace will be `development` if `identifier` is empty (or wasn't set).
pub fn set_application_namespace(identifier: impl Into<String>) {
*NAMESPACE.lock().unwrap() = identifier.into()
}
fn entry_for(handle: &str, namespace: Namespace) -> Result<keyring::Entry> {
let ns = match namespace {
Namespace::BuildKind => NAMESPACE.lock().unwrap().clone(),
Namespace::Global => "gitbutler".into(),
};
Ok(keyring::Entry::new(
&format!(
"{prefix}-{handle}",
prefix = if ns.is_empty() { "development" } else { &ns }
),
"GitButler",
)?)
}
/// How to further specialize secrets to avoid name clashes in the globally shared keystore.
static NAMESPACE: Mutex<String> = Mutex::new(String::new());
/// A keystore that uses git-credentials under to hood. It's useful on Systems that nag the user
/// with popups if the underlying binary changes, and is available if `git` can be found and executed.
pub mod git_credentials {
use anyhow::Result;
use keyring::credential::{CredentialApi, CredentialBuilderApi, CredentialPersistence};
use keyring::Credential;
use std::any::Any;
use std::sync::Arc;
use tracing::instrument;
pub(super) struct Store(gix::config::File<'static>);
impl Store {
/// Create an instance by resolving the global environment just well enough.
///
/// # Limitation
///
/// This does not fully resolve includes, so it's not truly production ready but should be
/// fine for developer setups.
fn from_globals() -> Result<Self> {
Ok(Store(gix::config::File::from_globals()?))
}
/// Provide credentials preconfigured for the given secrets `handle`.
/// They can then be queried.
fn credentials(
&self,
handle: &str,
password: Option<&str>,
) -> Result<(
gix::credentials::helper::Cascade,
gix::credentials::helper::Action,
gix::prompt::Options<'static>,
)> {
let url = gix::Url::from_parts(
gix::url::Scheme::Https,
Some("gitbutler-secrets".into()),
password.map(ToOwned::to_owned),
Some("gitbutler.com".into()),
None,
format!("/{handle}").into(),
false,
)?;
gix::config::credential_helpers(
url,
&self.0,
true,
&mut gix::config::section::is_trusted,
gix::open::permissions::Environment::isolated(),
true, /* use http path by default */
)
.map(|mut t| {
let ctx = t.1.context_mut().expect("get always has context");
// Assure the context has fields for all parts in the URL, even
// if later we choose to use store or erase actions.
// Usually these are naturally populated,
// but not if we do everything by hand.
// This is not a shortcoming in `gitoxide` - it simply doesn't touch
// the output of previous invocations to not unintentionally affect them.
ctx.destructure_url_in_place(true /* use http path */)
.expect("input URL is valid");
t.2.mode = gix::prompt::Mode::Disable;
t
})
.map_err(Into::into)
}
}
pub(super) type SharedStore = Arc<Store>;
struct Entry {
handle: String,
store: SharedStore,
}
impl CredentialApi for Entry {
#[instrument(skip(self, password), err(Debug))]
fn set_password(&self, password: &str) -> keyring::Result<()> {
// credential helper on macos can't overwrite existing values apparently, workaround that.
#[cfg(target_os = "macos")]
self.delete_password().ok();
let (mut cascade, action, prompt) = self
.store
.credentials(&self.handle, Some(password))
.map_err(|err| keyring::Error::PlatformFailure(err.into()))?;
let ctx = action.context().expect("available for get").to_owned();
let action = gix::credentials::helper::NextAction::from(ctx).store();
cascade
.invoke(action, prompt)
.map_err(|err| keyring::Error::PlatformFailure(err.into()))?;
Ok(())
}
#[instrument(skip(self), err(Debug))]
fn get_password(&self) -> keyring::Result<String> {
let (mut cascade, get_action, prompt) = self
.store
.credentials(&self.handle, None)
.map_err(|err| keyring::Error::PlatformFailure(err.into()))?;
match cascade.invoke(get_action, prompt) {
Ok(Some(out)) => Ok(out.identity.password),
Ok(None) => Err(keyring::Error::NoEntry),
Err(err) => {
tracing::debug!(err = ?err, "credential-helper invoke failed - usually this means it wanted to prompt which is disabled");
Err(keyring::Error::NoEntry)
}
}
}
#[instrument(skip(self), err(Debug))]
fn delete_password(&self) -> keyring::Result<()> {
let (mut cascade, action, prompt) = self
.store
.credentials(&self.handle, None)
.map_err(|err| keyring::Error::PlatformFailure(err.into()))?;
let ctx = action.context().expect("available for get").to_owned();
let action = gix::credentials::helper::NextAction::from(ctx).erase();
cascade
.invoke(action, prompt)
.map_err(|err| keyring::Error::PlatformFailure(err.into()))?;
Ok(())
}
fn as_any(&self) -> &dyn Any {
self
}
}
pub(super) struct Builder {
pub(super) store: SharedStore,
}
impl CredentialBuilderApi for Builder {
fn build(
&self,
_target: Option<&str>,
service: &str,
_user: &str,
) -> keyring::Result<Box<Credential>> {
let credential = Entry {
handle: service.to_string(),
store: self.store.clone(),
};
Ok(Box::new(credential))
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
/// We keep information in memory
fn persistence(&self) -> CredentialPersistence {
CredentialPersistence::UntilReboot
}
}
/// Initialize the credentials store so that secrets are using `git credential`.
#[instrument(err(Debug))]
pub fn setup() -> Result<()> {
let store = Arc::new(Store::from_globals()?);
keyring::set_default_credential_builder(Box::new(Builder { store }));
Ok(())
}
}

View File

@ -0,0 +1,94 @@
use crate::virtual_branches::branch::HunkHash;
use bstr::{BString, ByteSlice};
use serde::Serialize;
pub fn as_string_lossy<S>(v: &BString, s: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
v.to_str_lossy().serialize(s)
}
pub fn hash_to_hex<S>(v: &HunkHash, s: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
format!("{v:x}").serialize(s)
}
pub fn as_time_seconds_from_unix_epoch<S>(v: &git2::Time, s: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
v.seconds().serialize(s)
}
pub mod oid_opt {
use serde::{Deserialize, Deserializer, Serialize};
pub fn serialize<S>(v: &Option<git2::Oid>, s: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
v.as_ref().map(|v| v.to_string()).serialize(s)
}
pub fn deserialize<'de, D>(d: D) -> Result<Option<git2::Oid>, D::Error>
where
D: Deserializer<'de>,
{
let hex = <Option<String> as Deserialize>::deserialize(d)?;
hex.map(|v| {
v.parse()
.map_err(|err: git2::Error| serde::de::Error::custom(err.to_string()))
})
.transpose()
}
}
pub mod oid_vec {
use serde::{Deserialize, Deserializer, Serialize};
pub fn serialize<S>(v: &[git2::Oid], s: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let vec: Vec<String> = v.iter().map(|v| v.to_string()).collect();
vec.serialize(s)
}
pub fn deserialize<'de, D>(d: D) -> Result<Vec<git2::Oid>, D::Error>
where
D: Deserializer<'de>,
{
let hex = <Vec<String> as Deserialize>::deserialize(d)?;
let hex: Result<Vec<git2::Oid>, D::Error> = hex
.into_iter()
.map(|v| {
git2::Oid::from_str(v.as_str())
.map_err(|err: git2::Error| serde::de::Error::custom(err.to_string()))
})
.collect();
hex
}
}
pub mod oid {
use serde::{Deserialize, Deserializer, Serialize};
pub fn serialize<S>(v: &git2::Oid, s: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
v.to_string().serialize(s)
}
pub fn deserialize<'de, D>(d: D) -> Result<git2::Oid, D::Error>
where
D: Deserializer<'de>,
{
let hex = String::deserialize(d)?;
hex.parse()
.map_err(|err: git2::Error| serde::de::Error::custom(err.to_string()))
}
}

View File

@ -6,11 +6,11 @@ impl<T> Serialize for Sensitive<T>
where
T: Serialize,
{
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
self.0.serialize(serializer)
unreachable!("BUG: Sensitive data cannot be serialized - it needs to be extracted and put into a struct for serialization explicitly")
}
}
impl<'de, T> Deserialize<'de> for Sensitive<T>

View File

@ -1,9 +1,16 @@
use super::{storage::Storage, User};
use crate::secret;
use anyhow::Context;
use anyhow::Result;
use std::path::PathBuf;
use super::{storage::Storage, User};
/// TODO(ST): useless intermediary - remove
/// TODO(ST): rename to `Login` - seems more akin to what it does
/// This type deals with user-related data which is only known if the user is logged in to GitButler.
///
/// ### Migrations: V1 -> V2
///
/// V2 is implied by not storing the `access_token` in plain text anymore, nor the GitHub token even if present.
/// It happens automatically on [Self::get_user()] and [Self::set_user()]
#[derive(Clone)]
pub struct Controller {
storage: Storage,
@ -18,21 +25,46 @@ impl Controller {
Controller::new(Storage::from_path(path))
}
pub fn get_user(&self) -> anyhow::Result<Option<User>> {
match self.storage.get().context("failed to get user") {
Ok(user) => Ok(user),
Err(err) => {
self.storage.delete().ok();
Err(err)
}
/// Return the current login, or `None` if there is none yet.
pub fn get_user(&self) -> Result<Option<User>> {
let user = self.storage.get().context("failed to get user")?;
if let Some(user) = &user {
write_without_secrets_if_secrets_present(&self.storage, user.clone())?;
}
Ok(user)
}
/// Note that secrets are never written in plain text, but we assure they are stored.
pub fn set_user(&self, user: &User) -> Result<()> {
if !write_without_secrets_if_secrets_present(&self.storage, user.clone())? {
self.storage.set(user).context("failed to set user")
} else {
Ok(())
}
}
pub fn set_user(&self, user: &User) -> anyhow::Result<()> {
self.storage.set(user).context("failed to set user")
}
pub fn delete_user(&self) -> anyhow::Result<()> {
self.storage.delete().context("failed to delete user")
pub fn delete_user(&self) -> Result<()> {
self.storage.delete().context("failed to delete user")?;
let namespace = secret::Namespace::BuildKind;
secret::delete(User::ACCESS_TOKEN_HANDLE, namespace).ok();
secret::delete(User::GITHUB_ACCESS_TOKEN_HANDLE, namespace).ok();
Ok(())
}
}
/// As `user` sports interior mutability right now, let's play it safe and work with fully owned items only.
fn write_without_secrets_if_secrets_present(storage: &Storage, user: User) -> Result<bool> {
let mut needs_write = false;
let namespace = secret::Namespace::BuildKind;
if let Some(gb_token) = user.access_token.borrow_mut().take() {
needs_write |= secret::persist(User::ACCESS_TOKEN_HANDLE, &gb_token, namespace).is_ok();
}
if let Some(gh_token) = user.github_access_token.borrow_mut().take() {
needs_write |=
secret::persist(User::GITHUB_ACCESS_TOKEN_HANDLE, &gh_token, namespace).is_ok();
}
if needs_write {
storage.set(&user)?;
}
Ok(needs_write)
}

View File

@ -1,5 +1,8 @@
use crate::secret;
use crate::types::Sensitive;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::cell::RefCell;
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct User {
@ -12,9 +15,49 @@ pub struct User {
pub locale: Option<String>,
pub created_at: String,
pub updated_at: String,
pub access_token: Sensitive<String>,
/// The presence of a GitButler access token is required for a valid user, but it's optional
/// as it's not actually stored anymore, but fetch on demand in a separate step as its
/// storage location is the [secrets store](crate::secret).
#[serde(skip_serializing)]
pub(super) access_token: RefCell<Option<Sensitive<String>>>,
pub role: Option<String>,
pub github_access_token: Option<Sensitive<String>>,
/// The semantics here are the same as for `access_token`, but this token is truly optional.
#[serde(skip_serializing)]
pub(super) github_access_token: RefCell<Option<Sensitive<String>>>,
#[serde(default)]
pub github_username: Option<String>,
}
impl User {
pub(super) const ACCESS_TOKEN_HANDLE: &'static str = "gitbutler_access_token";
pub(super) const GITHUB_ACCESS_TOKEN_HANDLE: &'static str = "github_access_token";
/// Return the access token of the user after fetching it from the secrets store.
///
/// It's cached after the first retrieval.
pub fn access_token(&self) -> Result<Sensitive<String>> {
if let Some(token) = self.access_token.borrow().as_ref() {
return Ok(token.clone());
}
let err_msg = "access token for user was deleted from keychain - login is now invalid";
let secret = secret::retrieve(Self::ACCESS_TOKEN_HANDLE, secret::Namespace::BuildKind)?
.context(err_msg)?;
*self.access_token.borrow_mut() = Some(secret.clone());
Ok(secret)
}
/// Obtain the GitHub access token, if it is stored either on this instance or in the secrets store.
///
/// Note that if retrieved from the secrets store, it will be cached on instance.
pub fn github_access_token(&self) -> Result<Option<Sensitive<String>>> {
if let Some(token) = self.github_access_token.borrow().as_ref() {
return Ok(Some(token.clone()));
}
let secret = secret::retrieve(
Self::GITHUB_ACCESS_TOKEN_HANDLE,
secret::Namespace::BuildKind,
)?;
self.github_access_token.borrow_mut().clone_from(&secret);
Ok(secret)
}
}

View File

@ -3739,7 +3739,7 @@ fn update_conflict_markers(
}
}
if !conflicted {
conflicts::resolve(project_repository, &file_path.display().to_string()).unwrap();
conflicts::resolve(project_repository, file_path.display().to_string()).unwrap();
}
}
}

View File

@ -0,0 +1,15 @@
{
"id": 13612,
"name": "Sebastian Thiel",
"given_name": null,
"family_name": null,
"email": "sebastian.thiel@icloud.com",
"picture": "https://avatars.githubusercontent.com/u/63622?v=4",
"locale": null,
"created_at": "2024-03-26T13:17:05Z",
"updated_at": "2024-06-24T15:21:45Z",
"access_token": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"role": null,
"github_access_token": null,
"github_username": null
}

View File

@ -0,0 +1,15 @@
{
"id": 13612,
"name": "Sebastian Thiel",
"given_name": null,
"family_name": null,
"email": "sebastian.thiel@icloud.com",
"picture": "https://avatars.githubusercontent.com/u/63622?v=4",
"locale": null,
"created_at": "2024-03-26T13:17:05Z",
"updated_at": "2024-06-24T15:21:45Z",
"access_token": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"role": null,
"github_access_token": "gho_AAAAAAAAAAAAABBBBBBBBBBBBBBBCCCCCCCC",
"github_username": null
}

View File

@ -1,7 +1,6 @@
use std::path::PathBuf;
use std::str;
use gitbutler_core::types::Sensitive;
use gitbutler_core::{
git::credentials::{Credential, Helper, SshCredential},
keys, project_repository, projects, users,
@ -12,7 +11,7 @@ use gitbutler_testsupport::{temp_dir, test_repository};
#[derive(Default)]
struct TestCase<'a> {
remote_url: &'a str,
github_access_token: Option<Sensitive<&'a str>>,
with_github_login: bool,
preferred_key: projects::AuthKey,
}
@ -20,11 +19,14 @@ impl TestCase<'_> {
fn run(&self) -> Vec<(String, Vec<Credential>)> {
let local_app_data = temp_dir();
gitbutler_testsupport::secrets::setup_blackhole_store();
let users = users::Controller::from_path(local_app_data.path());
let user = users::User {
github_access_token: self.github_access_token.map(|s| Sensitive(s.0.to_string())),
..Default::default()
};
let user: users::User = serde_json::from_str(if self.with_github_login {
include_str!("../../tests/fixtures/users/with-github.v1")
} else {
include_str!("../../tests/fixtures/users/login-only.v1")
})
.expect("valid v1 sample user");
users.set_user(&user).unwrap();
let keys = keys::Controller::from_path(local_app_data.path());
@ -56,7 +58,7 @@ mod not_github {
fn https() {
let test_case = TestCase {
remote_url: "https://gitlab.com/test-gitbutler/test.git",
github_access_token: Some(Sensitive("token")),
with_github_login: true,
preferred_key: projects::AuthKey::Local {
private_key_path: PathBuf::from("/tmp/id_rsa"),
},
@ -80,7 +82,7 @@ mod not_github {
fn ssh() {
let test_case = TestCase {
remote_url: "git@gitlab.com:test-gitbutler/test.git",
github_access_token: Some(Sensitive("token")),
with_github_login: true,
preferred_key: projects::AuthKey::Local {
private_key_path: PathBuf::from("/tmp/id_rsa"),
},
@ -115,7 +117,7 @@ mod github {
fn https() {
let test_case = TestCase {
remote_url: "https://github.com/gitbutlerapp/gitbutler.git",
github_access_token: Some(Sensitive("token")),
with_github_login: true,
preferred_key: projects::AuthKey::Local {
private_key_path: PathBuf::from("/tmp/id_rsa"),
},
@ -139,7 +141,7 @@ mod github {
fn ssh() {
let test_case = TestCase {
remote_url: "git@github.com:gitbutlerapp/gitbutler.git",
github_access_token: Some(Sensitive("token")),
with_github_login: true,
preferred_key: projects::AuthKey::Local {
private_key_path: PathBuf::from("/tmp/id_rsa"),
},

View File

@ -0,0 +1,96 @@
use keyring::credential::{CredentialApi, CredentialBuilderApi, CredentialPersistence};
use keyring::Credential;
use std::any::Any;
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
#[derive(Default)]
pub(super) struct Store(BTreeMap<String, String>);
pub(super) type SharedStore = Arc<Mutex<Store>>;
struct Entry {
handle: String,
store: SharedStore,
}
impl CredentialApi for Entry {
fn set_password(&self, password: &str) -> keyring::Result<()> {
self.store
.lock()
.unwrap()
.0
.insert(self.handle.clone(), password.into());
Ok(())
}
fn get_password(&self) -> keyring::Result<String> {
match self.store.lock().unwrap().0.get(&self.handle) {
Some(secret) => Ok(secret.clone()),
None => Err(keyring::Error::NoEntry),
}
}
fn delete_password(&self) -> keyring::Result<()> {
self.store.lock().unwrap().0.remove(&self.handle);
Ok(())
}
fn as_any(&self) -> &dyn Any {
self
}
}
pub(super) struct Builder {
pub(super) store: SharedStore,
}
impl CredentialBuilderApi for Builder {
fn build(
&self,
_target: Option<&str>,
service: &str,
_user: &str,
) -> keyring::Result<Box<Credential>> {
let credential = Entry {
handle: service.to_string(),
store: self.store.clone(),
};
Ok(Box::new(credential))
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
/// We keep information in memory
fn persistence(&self) -> CredentialPersistence {
CredentialPersistence::ProcessOnly
}
}
static CURRENT_STORE: Mutex<Option<SharedStore>> = Mutex::new(None);
/// Initialize the credentials store to be isolated and usable for testing.
///
/// Note that this is a resource shared in the process, and deterministic tests must
/// use the `[serial]` annotation.
pub fn setup() {
let store = SharedStore::default();
*CURRENT_STORE.lock().unwrap() = Some(store.clone());
keyring::set_default_credential_builder(Box::new(Builder { store }));
}
/// Return the amount of stored secrets
pub fn count() -> usize {
CURRENT_STORE
.lock()
.unwrap()
.as_ref()
.expect("BUG: call setup")
.lock()
.unwrap()
.0
.len()
}

View File

@ -0,0 +1,59 @@
//! Note that these tests *must* be run in their own process, as they rely on having a deterministic
//! credential store. Due to its global nature, tests cannot run in parallel
//! (or mixed with parallel tests that set their own credential store)
use gitbutler_core::secret;
use gitbutler_core::types::Sensitive;
use serial_test::serial;
#[test]
#[serial]
fn retrieve_unknown_is_none() {
credentials::setup();
for ns in all_namespaces() {
assert!(secret::retrieve("does not exist for sure", *ns)
.expect("no error to ask for non-existing")
.is_none());
}
}
#[test]
#[serial]
fn store_and_retrieve() -> anyhow::Result<()> {
credentials::setup();
for ns in all_namespaces() {
secret::persist("new", &Sensitive("secret".into()), *ns)?;
let secret = secret::retrieve("new", *ns)?.expect("it was just stored");
assert_eq!(
secret.0, "secret",
"note that this works only if the engine supports actual persistence, \
which should be the default outside of tests"
);
}
Ok(())
}
#[test]
#[serial]
fn store_empty_equals_deletion() -> anyhow::Result<()> {
credentials::setup();
for ns in all_namespaces() {
secret::persist("new", &Sensitive("secret".into()), *ns)?;
assert_eq!(credentials::count(), 1);
secret::persist("new", &Sensitive("".into()), *ns)?;
assert_eq!(
secret::retrieve("new", *ns)?.map(|s| s.0),
None,
"empty passwords are automatically deleted"
);
assert_eq!(credentials::count(), 0);
}
Ok(())
}
fn all_namespaces() -> &'static [secret::Namespace] {
&[secret::Namespace::Global, secret::Namespace::BuildKind]
}
pub(crate) mod credentials;
mod users;

View File

@ -0,0 +1,119 @@
use crate::{credentials, credentials::count as count_secrets};
use gitbutler_core::users::User;
use serial_test::serial;
use std::path::{Path, PathBuf};
use tempfile::tempdir;
/// Validate that secrets previously stored in plain-text are auto-migrated into the secrets store.
/// From there, data-structures for use by the frontend need to be 'enriched' with secrets before sending them,
/// or before using them.
#[test]
#[serial]
fn auto_migration_of_secrets_on_when_getting_and_setting_user() -> anyhow::Result<()> {
for (name, has_github_token) in [("login-only.v1", false), ("with-github.v1", true)] {
credentials::setup();
let app_data = tempdir()?;
let users = gitbutler_core::users::Controller::from_path(app_data.path());
assert!(
users.get_user()?.is_none(),
"Users are bound to logins, so there is none by default"
);
assert_eq!(count_secrets(), 0, "no secret is associated with anything");
let buf = std::fs::read(user_fixture(name))?;
let user_json_path = app_data.path().join("user.json");
std::fs::write(&user_json_path, &buf)?;
let user = users.get_user()?.expect("previous v1 user was read");
let expected_secrets = if has_github_token { 2 } else { 1 };
assert_eq!(
count_secrets(),
expected_secrets,
"it automatically entered the secrets to the secrets store after getting the existing user"
);
let assert_access_token_values = |user: &User| -> anyhow::Result<()> {
assert_eq!(
user.access_token()?.0,
"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"it can make the access token available"
);
if has_github_token {
assert_eq!(
user.github_access_token()?.map(|s| s.0),
Some("gho_AAAAAAAAAAAAABBBBBBBBBBBBBBBCCCCCCCC".into()),
"it can make the access token available"
);
}
Ok(())
};
assert_access_token_values(&user)?;
let assert_no_secret_in_plain_text = || -> anyhow::Result<()> {
let buf = std::fs::read(&user_json_path)?;
let value: serde_json::Value = serde_json::from_slice(&buf)?;
assert_eq!(
value.get("access_token"),
None,
"access token wasn't written back (right after getting it)"
);
assert_eq!(
value.get("github_access_token"),
None,
"access token wasn't written back"
);
Ok(())
};
assert_no_secret_in_plain_text()?;
let user = users.get_user()?.expect("stored user can be read");
assert_access_token_values(&user)?;
users.delete_user()?;
assert_eq!(
count_secrets(),
0,
"deletion of a user automatically deletes its secretes"
);
assert!(
!user_json_path.exists(),
"it deletes the whole file, i.e. all associated user data"
);
users.set_user(&user)?;
assert_eq!(
count_secrets(),
expected_secrets,
"the in-memory users had its secrets cached, so they are picked up and stored officially. \
This is important, as the frontend sends these initially"
);
assert_no_secret_in_plain_text()?;
// forget all passwords
credentials::setup();
let user = users
.get_user()?
.expect("user still on disk and passwords are accessed lazily");
assert!(
user.access_token().is_err(),
"this is critical - we have a user without access token, this fails early"
);
assert!(
users.get_user()?.is_some(),
"Client code needs to handle this case and delete the user, \
otherwise it's there and errors forever"
);
}
Ok(())
}
fn user_fixture(name: &str) -> PathBuf {
let fixture = Path::new("tests/fixtures/users").join(name);
assert!(
fixture.exists(),
"BUG: fixture at {fixture:?} ought to exist"
);
fixture
}

View File

@ -25,7 +25,7 @@ async fn workdir_vbranch_restore() -> anyhow::Result<()> {
let line_count = round * 20;
fs::write(
worktree_dir.join(format!("file{round}.txt")),
&make_lines(line_count),
make_lines(line_count),
)?;
let branch_id = controller
.create_virtual_branch(

View File

@ -31,6 +31,8 @@ impl Socket for BufStream<UnixStream> {
async fn read_line(&mut self) -> Result<String, Self::Error> {
let mut buf = String::new();
<Self as AsyncBufReadExt>::read_line(self, &mut buf).await?;
// TODO: use an array of `char`
#[allow(clippy::manual_pattern_char_comparison)]
Ok(buf.trim_end_matches(|c| c == '\r' || c == '\n').into())
}

View File

@ -14,7 +14,7 @@ path = "src/main.rs"
test = false
[build-dependencies]
tauri-build = { version = "1.5", features = [] }
tauri-build = { version = "1.5.3", features = [] }
[dev-dependencies]
pretty_assertions = "1.4"
@ -51,7 +51,7 @@ gitbutler-watcher.workspace = true
open = "5"
[dependencies.tauri]
version = "1.6.8"
version = "1.7.0"
features = [
"http-all", "os-all", "dialog-open", "fs-read-file",
"path-all", "process-relaunch", "protocol-asset",

View File

@ -79,6 +79,11 @@ impl App {
Ok(value.to_string())
}
pub fn git_remove_global_config(key: &str) -> Result<()> {
let mut config = git2::Config::open_default()?;
Ok(config.remove(key)?)
}
pub fn git_get_global_config(key: &str) -> Result<Option<String>> {
let config = git2::Config::open_default()?;
let value = config.get_string(key);

View File

@ -103,6 +103,12 @@ pub async fn git_set_global_config(
Ok(result)
}
#[tauri::command(async)]
#[instrument(err(Debug))]
pub async fn git_remove_global_config(key: &str) -> Result<(), Error> {
Ok(app::App::git_remove_global_config(key)?)
}
#[tauri::command(async)]
#[instrument(skip(_handle), err(Debug))]
pub async fn git_get_global_config(

View File

@ -27,7 +27,9 @@ pub mod github;
pub mod keys;
pub mod projects;
pub mod remotes;
pub mod secret;
pub mod undo;
pub mod users;
pub mod virtual_branches;
pub mod zip;

View File

@ -15,14 +15,17 @@
use gitbutler_core::{assets, git, storage};
use gitbutler_tauri::{
app, askpass, commands, config, github, keys, logs, menu, projects, remotes, undo, users,
virtual_branches, watcher, zip,
app, askpass, commands, config, github, keys, logs, menu, projects, remotes, secret, undo,
users, virtual_branches, watcher, zip,
};
use tauri::{generate_context, Manager};
use tauri_plugin_log::LogTarget;
fn main() {
let tauri_context = generate_context!();
gitbutler_core::secret::set_application_namespace(
&tauri_context.config().tauri.bundle.identifier,
);
tokio::runtime::Builder::new_multi_thread()
.enable_all()
@ -66,6 +69,14 @@ fn main() {
logs::init(&app_handle);
// On MacOS, in dev mode with debug assertions, we encounter popups each time
// the binary is rebuilt. To counter that, use a git-credential based implementation.
// This isn't an issue for actual release build (i.e. nightly, production),
// hence the specific condition.
if cfg!(debug_assertions) && cfg!(target_os = "macos") {
gitbutler_core::secret::git_credentials::setup().ok();
}
// SAFETY(qix-): This is safe because we're initializing the askpass broker here,
// SAFETY(qix-): before any other threads would ever access it.
unsafe {
@ -157,6 +168,7 @@ fn main() {
commands::delete_all_data,
commands::mark_resolved,
commands::git_set_global_config,
commands::git_remove_global_config,
commands::git_get_global_config,
commands::git_test_push,
commands::git_test_fetch,
@ -204,6 +216,8 @@ fn main() {
virtual_branches::commands::squash_branch_commit,
virtual_branches::commands::fetch_from_remotes,
virtual_branches::commands::move_commit,
secret::secret_get_global,
secret::secret_set_global,
undo::list_snapshots,
undo::restore_snapshot,
undo::snapshot_diff,

View File

@ -0,0 +1,23 @@
use crate::error::Error;
use gitbutler_core::secret;
use gitbutler_core::types::Sensitive;
use std::sync::Mutex;
use tracing::instrument;
#[tauri::command(async)]
#[instrument(err(Debug))]
pub async fn secret_get_global(handle: &str) -> Result<Option<String>, Error> {
Ok(secret::retrieve(handle, secret::Namespace::Global)?.map(|s| s.0))
}
#[tauri::command(async)]
#[instrument(skip(secret), err(Debug), fields(secret = "<redacted>"))]
pub async fn secret_set_global(handle: &str, secret: String) -> Result<(), Error> {
static FAIR_QUEUE: Mutex<()> = Mutex::new(());
let _one_at_a_time_to_prevent_races = FAIR_QUEUE.lock().unwrap();
Ok(secret::persist(
handle,
&Sensitive(secret),
secret::Namespace::Global,
)?)
}

View File

@ -3,6 +3,7 @@ pub mod commands {
assets,
users::{controller::Controller, User},
};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Manager};
use tracing::instrument;
@ -10,12 +11,18 @@ pub mod commands {
#[tauri::command(async)]
#[instrument(skip(handle), err(Debug))]
pub async fn get_user(handle: AppHandle) -> Result<Option<User>, Error> {
pub async fn get_user(handle: AppHandle) -> Result<Option<UserWithSecrets>, Error> {
let app = handle.state::<Controller>();
let proxy = handle.state::<assets::Proxy>();
match app.get_user()? {
Some(user) => Ok(Some(proxy.proxy_user(user).await)),
Some(user) => {
if let Err(err) = user.access_token() {
app.delete_user()?;
return Err(err.context("Please login to GitButler again").into());
}
Ok(Some(proxy.proxy_user(user).await.try_into()?))
}
None => Ok(None),
}
}
@ -40,4 +47,59 @@ pub mod commands {
Ok(())
}
#[derive(Debug, Deserialize, Serialize)]
pub struct UserWithSecrets {
id: u64,
name: Option<String>,
given_name: Option<String>,
family_name: Option<String>,
email: String,
picture: String,
locale: Option<String>,
created_at: String,
updated_at: String,
access_token: String,
role: Option<String>,
github_access_token: Option<String>,
github_username: Option<String>,
}
impl TryFrom<User> for UserWithSecrets {
type Error = anyhow::Error;
fn try_from(value: User) -> Result<Self, Self::Error> {
let access_token = value.access_token()?;
let github_access_token = value.github_access_token()?;
let User {
id,
name,
given_name,
family_name,
email,
picture,
locale,
created_at,
updated_at,
role,
github_username,
..
} = value;
Ok(UserWithSecrets {
id,
name,
given_name,
family_name,
email,
picture,
locale,
created_at,
updated_at,
access_token: access_token.0,
role,
github_access_token: github_access_token.map(|s| s.0),
github_username,
})
}
}
}

View File

@ -15,4 +15,6 @@ once_cell = "1.19"
git2.workspace = true
pretty_assertions = "1.4"
tempfile = "3.10.1"
keyring.workspace = true
serde_json = "1.0"
gitbutler-core = { path = "../gitbutler-core" }

View File

@ -0,0 +1,15 @@
{
"id": 0,
"name": "test",
"given_name": null,
"family_name": null,
"email": "test@email.com",
"picture": "",
"locale": null,
"created_at": "",
"updated_at": "",
"access_token": "token",
"role": null,
"github_access_token": null,
"github_username": null
}

View File

@ -60,3 +60,9 @@ pub fn init_opts_bare() -> git2::RepositoryInitOptions {
opts.bare(true);
opts
}
/// A secrets store to prevent secrets to be written into the systems own store.
///
/// Note that this can't be used if secrets themselves are under test as it' doesn't act
/// like a real store, i.e. stored secrets can't be retrieved anymore.
pub mod secrets;

View File

@ -0,0 +1,45 @@
use std::any::Any;
/// Assure we have a mock secrets store so tests don't start writing secrets into the user's actual store,
/// as this will affect their GitButler instance.
pub fn setup_blackhole_store() {
keyring::set_default_credential_builder(Box::new(BlackholeBuilder))
}
/// A builder that is completely mocked, able to read no secret, but allowing to write any without error.
struct BlackholeBuilder;
struct BlackholeCredential;
impl keyring::credential::CredentialApi for BlackholeCredential {
fn set_password(&self, _password: &str) -> keyring::Result<()> {
Ok(())
}
fn get_password(&self) -> keyring::Result<String> {
Err(keyring::Error::NoEntry)
}
fn delete_password(&self) -> keyring::Result<()> {
Ok(())
}
fn as_any(&self) -> &dyn Any {
self
}
}
impl keyring::credential::CredentialBuilderApi for BlackholeBuilder {
fn build(
&self,
_target: Option<&str>,
_service: &str,
_user: &str,
) -> keyring::Result<Box<keyring::Credential>> {
Ok(Box::new(BlackholeCredential))
}
fn as_any(&self) -> &dyn Any {
self
}
}

View File

@ -4,7 +4,6 @@ use std::{
path::{Path, PathBuf},
};
use gitbutler_core::types::Sensitive;
use gitbutler_core::{git::RepositoryExt, project_repository};
use tempfile::{tempdir, TempDir};
@ -48,12 +47,10 @@ impl Suite {
self.local_app_data.as_ref().unwrap().path()
}
pub fn sign_in(&self) -> gitbutler_core::users::User {
let user = gitbutler_core::users::User {
name: Some("test".to_string()),
email: "test@email.com".to_string(),
access_token: Sensitive("token".to_string()),
..Default::default()
};
crate::secrets::setup_blackhole_store();
let user: gitbutler_core::users::User =
serde_json::from_str(include_str!("fixtures/user/minimal.v1"))
.expect("valid v1 user file");
self.users.set_user(&user).expect("failed to add user");
user
}

View File

@ -102,6 +102,8 @@ impl FileIdMap {
fn dir_scan_depth(is_recursive: bool) -> usize {
if is_recursive {
// TODO
#[allow(clippy::legacy_numeric_constants)]
usize::max_value()
} else {
1

View File

@ -98,12 +98,14 @@ where
}
}
#[cfg(feature = "crossbeam")]
impl DebounceEventHandler for crossbeam_channel::Sender<DebounceEventResult> {
fn handle_event(&mut self, event: DebounceEventResult) {
let _ = self.send(event);
}
}
// Unlike https://github.com/notify-rs/notify/blob/main/notify-debouncer-full/Cargo.toml#L19 we don't seem to be setting this feature
//
// #[cfg(feature = "crossbeam")]
// impl DebounceEventHandler for crossbeam_channel::Sender<DebounceEventResult> {
// fn handle_event(&mut self, event: DebounceEventResult) {
// let _ = self.send(event);
// }
// }
impl DebounceEventHandler for std::sync::mpsc::Sender<DebounceEventResult> {
fn handle_event(&mut self, event: DebounceEventResult) {

View File

@ -25,7 +25,7 @@
},
"devDependencies": {
"@eslint/js": "^9.5.0",
"@tauri-apps/cli": "^1.5.13",
"@tauri-apps/cli": "^1.6.0",
"@types/eslint__js": "^8.42.3",
"@typescript-eslint/parser": "^7.13.1",
"eslint": "^9.5.0",

View File

@ -12,8 +12,8 @@ importers:
specifier: ^9.5.0
version: 9.5.0
'@tauri-apps/cli':
specifier: ^1.5.13
version: 1.5.14
specifier: ^1.6.0
version: 1.6.0
'@types/eslint__js':
specifier: ^8.42.3
version: 8.42.3
@ -151,8 +151,11 @@ importers:
specifier: ^3.1.1
version: 3.1.1(svelte@5.0.0-next.149)(vite@5.2.13(@types/node@20.5.9))
'@tauri-apps/api':
specifier: ^1.5.5
version: 1.5.6
specifier: ^1.6.0
version: 1.6.0
'@types/crypto-js':
specifier: ^4.2.2
version: 4.2.2
'@types/diff':
specifier: ^5.2.1
version: 5.2.1
@ -2199,72 +2202,72 @@ packages:
resolution: {integrity: sha512-zxnDjHHKjOsrIzZm6nO5Xapb/BxqUq1tc7cGkFXsFkGTsSWgCPH1D8mm0XS9weJY2OaR73I3k3S+b7eSzJDfqA==}
engines: {node: '>= 14.6.0', npm: '>= 6.6.0', yarn: '>= 1.19.1'}
'@tauri-apps/api@1.5.6':
resolution: {integrity: sha512-LH5ToovAHnDVe5Qa9f/+jW28I6DeMhos8bNDtBOmmnaDpPmJmYLyHdeDblAWWWYc7KKRDg9/66vMuKyq0WIeFA==}
'@tauri-apps/api@1.6.0':
resolution: {integrity: sha512-rqI++FWClU5I2UBp4HXFvl+sBWkdigBkxnpJDQUWttNyG7IZP4FwQGhTNL5EOw0vI8i6eSAJ5frLqO7n7jbJdg==}
engines: {node: '>= 14.6.0', npm: '>= 6.6.0', yarn: '>= 1.19.1'}
'@tauri-apps/cli-darwin-arm64@1.5.14':
resolution: {integrity: sha512-lxoSOp3KKSqzHJa7iT32dukPGMlfsTuja1xXSgwR8o/fqzpYJY7FY/3ZxesP8HR66FcK+vtqa//HNqeOQ0mHkA==}
'@tauri-apps/cli-darwin-arm64@1.6.0':
resolution: {integrity: sha512-SNRwUD9nqGxY47mbY1CGTt/jqyQOU7Ps7Mx/mpgahL0FVUDiCEY/5L9QfEPPhEgccgcelEVn7i6aQHIkHyUtCA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@tauri-apps/cli-darwin-x64@1.5.14':
resolution: {integrity: sha512-EXSwN1n5spfG8FoXuyc90ACtmDJXzaZ1gxyENaq9xEpQoo7j/Q1vb6qXxmr6azKr8zmqY4h08ZFbv3exh93xJg==}
'@tauri-apps/cli-darwin-x64@1.6.0':
resolution: {integrity: sha512-g2/uDR/eeH2arvuawA4WwaEOqv/7jDO/ZLNI3JlBjP5Pk8GGb3Kdy0ro1xQzF94mtk2mOnOXa4dMgAet4sUJ1A==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@tauri-apps/cli-linux-arm-gnueabihf@1.5.14':
resolution: {integrity: sha512-Yb8BH/KYR7Tl+de40sZPfrqbhcU3Jlu+UPIrnXt05sjn48xqIps74Xjz8zzVp0TuHxUp8FmIGtCVhQgsbrsvvg==}
'@tauri-apps/cli-linux-arm-gnueabihf@1.6.0':
resolution: {integrity: sha512-EVwf4oRkQyG8BpSrk0gqO7oA0sDM2MdNDtJpMfleYFEgCxLIOGZKNqaOW3M7U+0Y4qikmG3TtRK+ngc8Ymtrjg==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@tauri-apps/cli-linux-arm64-gnu@1.5.14':
resolution: {integrity: sha512-QrKHP4gRaHiup478rPBZ+BmNd88yze9jMmheoNy9mN1K/aECRmTHO+tWhsxv5moFHZzRhO0QDWxxvTtiaPXaGg==}
'@tauri-apps/cli-linux-arm64-gnu@1.6.0':
resolution: {integrity: sha512-YdpY17cAySrhK9dX4BUVEmhAxE2o+6skIEFg8iN/xrDwRxhaNPI9I80YXPatUTX54Kx55T5++25VJG9+3iw83A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@tauri-apps/cli-linux-arm64-musl@1.5.14':
resolution: {integrity: sha512-Hb1C1VMxmUcyGjW/K/INKF87zzzgLEVRmWZZnQd7M1P4uue4xPyIwUELSdX12Z2jREPgmLW4AXPD0m6wsNu7iw==}
'@tauri-apps/cli-linux-arm64-musl@1.6.0':
resolution: {integrity: sha512-4U628tuf2U8pMr4tIBJhEkrFwt+46dwhXrDlpdyWSZtnop5RJAVKHODm0KbWns4xGKfTW1F3r6sSv+2ZxLcISA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@tauri-apps/cli-linux-x64-gnu@1.5.14':
resolution: {integrity: sha512-kD9v/UwPDuhIgq2TJj/s2/7rqk+vmExVV6xHPKI8vVbIvlNAOZqmx3fpxjej1241vhJ/piGd/m6q6YMWGsL0oQ==}
'@tauri-apps/cli-linux-x64-gnu@1.6.0':
resolution: {integrity: sha512-AKRzp76fVUaJyXj5KRJT9bJyhwZyUnRQU0RqIRqOtZCT5yr6qGP8rjtQ7YhCIzWrseBlOllc3Qvbgw3Yl0VQcA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@tauri-apps/cli-linux-x64-musl@1.5.14':
resolution: {integrity: sha512-204Drgg9Zx0+THKndqASz4+iPCwqA3gQVF9C0CDIArNXrjPyJjVvW8VP5CHiZYaTNWxlz/ltyxluM6UFWbXNFw==}
'@tauri-apps/cli-linux-x64-musl@1.6.0':
resolution: {integrity: sha512-0edIdq6aMBTaRMIXddHfyAFL361JqulLLd2Wi2aoOie7DkQ2MYh6gv3hA7NB9gqFwNIGE+xtJ4BkXIP2tSGPlg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@tauri-apps/cli-win32-arm64-msvc@1.5.14':
resolution: {integrity: sha512-sqPSni2MnWNCm+8YZnLdWCclxfSHaYqKuPFSz8q7Tn1G1m/cA9gyPoC1G0esHftY7bu/ZM5lB4kM3I4U0KlLiA==}
'@tauri-apps/cli-win32-arm64-msvc@1.6.0':
resolution: {integrity: sha512-QwWpWk4ubcwJ1rljsRAmINgB2AwkyzZhpYbalA+MmzyYMREcdXWGkyixWbRZgqc6fEWEBmq5UG73qz5eBJiIKg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@tauri-apps/cli-win32-ia32-msvc@1.5.14':
resolution: {integrity: sha512-8xN8W0zTs8oFsQmvYLxHFeqhzVI7oTaPK1xQMc5gbpFP45jN41c21aCXfjnvzT+h90EfCHUF9EWj2HTEJSb7Iw==}
'@tauri-apps/cli-win32-ia32-msvc@1.6.0':
resolution: {integrity: sha512-Vtw0yxO9+aEFuhuxQ57ALG43tjECopRimRuKGbtZYDCriB/ty5TrT3QWMdy0dxBkpDTu3Rqsz30sbDzw6tlP3Q==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@tauri-apps/cli-win32-x64-msvc@1.5.14':
resolution: {integrity: sha512-U0slee5tNM2PYECBpPHavLSwkT3szGMZ+qhcikQQbDan84bQdLn/kHWjyXOgLJs4KSve4+KxcrN+AVqj0VyHnw==}
'@tauri-apps/cli-win32-x64-msvc@1.6.0':
resolution: {integrity: sha512-h54FHOvGi7+LIfRchzgZYSCHB1HDlP599vWXQQJ/XnwJY+6Rwr2E5bOe/EhqoG8rbGkfK0xX3KPAvXPbUlmggg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@tauri-apps/cli@1.5.14':
resolution: {integrity: sha512-JOSMKymlg116UdEXSj69eg5p1OtZnQkUE0qIGbtNDO1sk3X/KgBN6+oHBW0BzPStp/W0AjBgrMWCqjHPwEpOug==}
'@tauri-apps/cli@1.6.0':
resolution: {integrity: sha512-DBBpBl6GhTzm8ImMbKkfaZ4fDTykWrC7Q5OXP4XqD91recmDEn2LExuvuiiS3HYe7uP8Eb5B9NPHhqJb+Zo7qQ==}
engines: {node: '>= 10'}
hasBin: true
@ -2332,6 +2335,9 @@ packages:
'@types/cross-spawn@6.0.6':
resolution: {integrity: sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==}
'@types/crypto-js@4.2.2':
resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==}
'@types/detect-port@1.3.5':
resolution: {integrity: sha512-Rf3/lB9WkDfIL9eEKaSYKc+1L/rNVYBjThk22JTqQw0YozXarX8YljFAz+HCoC6h4B4KwCMsBPZHaFezwT4BNA==}
@ -8651,50 +8657,50 @@ snapshots:
'@tauri-apps/api@1.5.3': {}
'@tauri-apps/api@1.5.6': {}
'@tauri-apps/api@1.6.0': {}
'@tauri-apps/cli-darwin-arm64@1.5.14':
'@tauri-apps/cli-darwin-arm64@1.6.0':
optional: true
'@tauri-apps/cli-darwin-x64@1.5.14':
'@tauri-apps/cli-darwin-x64@1.6.0':
optional: true
'@tauri-apps/cli-linux-arm-gnueabihf@1.5.14':
'@tauri-apps/cli-linux-arm-gnueabihf@1.6.0':
optional: true
'@tauri-apps/cli-linux-arm64-gnu@1.5.14':
'@tauri-apps/cli-linux-arm64-gnu@1.6.0':
optional: true
'@tauri-apps/cli-linux-arm64-musl@1.5.14':
'@tauri-apps/cli-linux-arm64-musl@1.6.0':
optional: true
'@tauri-apps/cli-linux-x64-gnu@1.5.14':
'@tauri-apps/cli-linux-x64-gnu@1.6.0':
optional: true
'@tauri-apps/cli-linux-x64-musl@1.5.14':
'@tauri-apps/cli-linux-x64-musl@1.6.0':
optional: true
'@tauri-apps/cli-win32-arm64-msvc@1.5.14':
'@tauri-apps/cli-win32-arm64-msvc@1.6.0':
optional: true
'@tauri-apps/cli-win32-ia32-msvc@1.5.14':
'@tauri-apps/cli-win32-ia32-msvc@1.6.0':
optional: true
'@tauri-apps/cli-win32-x64-msvc@1.5.14':
'@tauri-apps/cli-win32-x64-msvc@1.6.0':
optional: true
'@tauri-apps/cli@1.5.14':
'@tauri-apps/cli@1.6.0':
optionalDependencies:
'@tauri-apps/cli-darwin-arm64': 1.5.14
'@tauri-apps/cli-darwin-x64': 1.5.14
'@tauri-apps/cli-linux-arm-gnueabihf': 1.5.14
'@tauri-apps/cli-linux-arm64-gnu': 1.5.14
'@tauri-apps/cli-linux-arm64-musl': 1.5.14
'@tauri-apps/cli-linux-x64-gnu': 1.5.14
'@tauri-apps/cli-linux-x64-musl': 1.5.14
'@tauri-apps/cli-win32-arm64-msvc': 1.5.14
'@tauri-apps/cli-win32-ia32-msvc': 1.5.14
'@tauri-apps/cli-win32-x64-msvc': 1.5.14
'@tauri-apps/cli-darwin-arm64': 1.6.0
'@tauri-apps/cli-darwin-x64': 1.6.0
'@tauri-apps/cli-linux-arm-gnueabihf': 1.6.0
'@tauri-apps/cli-linux-arm64-gnu': 1.6.0
'@tauri-apps/cli-linux-arm64-musl': 1.6.0
'@tauri-apps/cli-linux-x64-gnu': 1.6.0
'@tauri-apps/cli-linux-x64-musl': 1.6.0
'@tauri-apps/cli-win32-arm64-msvc': 1.6.0
'@tauri-apps/cli-win32-ia32-msvc': 1.6.0
'@tauri-apps/cli-win32-x64-msvc': 1.6.0
'@testing-library/dom@9.3.4':
dependencies:
@ -8764,6 +8770,8 @@ snapshots:
dependencies:
'@types/node': 20.5.9
'@types/crypto-js@4.2.2': {}
'@types/detect-port@1.3.5': {}
'@types/diff-match-patch@1.0.36': {}
@ -12249,7 +12257,7 @@ snapshots:
tauri-plugin-context-menu@0.7.0:
dependencies:
'@tauri-apps/api': 1.5.6
'@tauri-apps/api': 1.6.0
tauri-plugin-log-api@https://codeload.github.com/tauri-apps/tauri-plugin-log/tar.gz/db7255ca2e07fc4d3e6cc5d93f9ccfceacb28901:
dependencies:

View File

@ -1,4 +1,4 @@
[toolchain]
channel = "nightly-2024-04-03"
channel = "nightly-2024-07-01"
components = [ "rustfmt", "rustc-dev", "clippy", "rust-src", "llvm-tools-preview" ]
profile = "minimal"