mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2024-10-03 23:49:20 +03:00
Merge remote-tracking branch 'origin/master' into ndom91/create-gitbutler-ui-package
This commit is contained in:
commit
ac4f3b926c
1450
Cargo.lock
generated
1450
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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" }
|
||||
|
@ -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",
|
||||
|
@ -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(
|
||||
|
@ -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() {
|
||||
|
@ -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;
|
||||
|
55
app/src/lib/secrets/secretsService.ts
Normal file
55
app/src/lib/secrets/secretsService.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
];
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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
|
||||
};
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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"
|
||||
|
@ -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(
|
||||
|
@ -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);
|
||||
|
@ -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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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(),
|
||||
)?;
|
||||
|
@ -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) {
|
||||
|
237
crates/gitbutler-core/src/secret.rs
Normal file
237
crates/gitbutler-core/src/secret.rs
Normal 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(())
|
||||
}
|
||||
}
|
94
crates/gitbutler-core/src/serde.rs
Normal file
94
crates/gitbutler-core/src/serde.rs
Normal 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()))
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
15
crates/gitbutler-core/tests/fixtures/users/login-only.v1
vendored
Normal file
15
crates/gitbutler-core/tests/fixtures/users/login-only.v1
vendored
Normal 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
|
||||
}
|
15
crates/gitbutler-core/tests/fixtures/users/with-github.v1
vendored
Normal file
15
crates/gitbutler-core/tests/fixtures/users/with-github.v1
vendored
Normal 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
|
||||
}
|
@ -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"),
|
||||
},
|
||||
|
96
crates/gitbutler-core/tests/secret/credentials.rs
Normal file
96
crates/gitbutler-core/tests/secret/credentials.rs
Normal 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()
|
||||
}
|
59
crates/gitbutler-core/tests/secret/mod.rs
Normal file
59
crates/gitbutler-core/tests/secret/mod.rs
Normal 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;
|
119
crates/gitbutler-core/tests/secret/users.rs
Normal file
119
crates/gitbutler-core/tests/secret/users.rs
Normal 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
|
||||
}
|
@ -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(
|
||||
|
@ -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())
|
||||
}
|
||||
|
||||
|
@ -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",
|
||||
|
@ -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);
|
||||
|
@ -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(
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
|
23
crates/gitbutler-tauri/src/secret.rs
Normal file
23
crates/gitbutler-tauri/src/secret.rs
Normal 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,
|
||||
)?)
|
||||
}
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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" }
|
||||
|
15
crates/gitbutler-testsupport/src/fixtures/user/minimal.v1
Normal file
15
crates/gitbutler-testsupport/src/fixtures/user/minimal.v1
Normal 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
|
||||
}
|
@ -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;
|
||||
|
45
crates/gitbutler-testsupport/src/secrets.rs
Normal file
45
crates/gitbutler-testsupport/src/secrets.rs
Normal 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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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) {
|
||||
|
@ -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",
|
||||
|
110
pnpm-lock.yaml
110
pnpm-lock.yaml
@ -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:
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user