Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/build-linux.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@ on:
paths:
- "linux/**"
- ".github/workflows/build-linux.yml"
- ".github/workflows/linux-packages.yml"
- ".github/workflows/release-linux.yml"
pull_request:
paths:
- "linux/**"
- ".github/workflows/build-linux.yml"
- ".github/workflows/linux-packages.yml"
- ".github/workflows/release-linux.yml"
schedule:
- cron: "13 14 * * 1"
workflow_dispatch:
Expand Down
15 changes: 13 additions & 2 deletions .github/workflows/release-linux.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ name: Release Linux
# for Fedora 44 through COPR. Flathub's own repository picks the new source
# tarball up from the release.
#
# Secret COPR_CONFIG: the API token file copr-cli reads (~/.config/copr).
# Environment linux-release, limited to linux-v* tags, holds the secret
# COPR_CONFIG: the API token file copr-cli reads (~/.config/copr).
# Variable LINUX_COPR_PROJECT: the COPR project, as owner/project.
# Variable LINUX_BRANCH: the branch a release tag must be on, linux when unset.

on:
push:
Expand All @@ -33,13 +35,19 @@ jobs:
apt-get install -y --no-install-recommends ca-certificates git meson python3
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: The tag, metainfo, spec and changelog name one version
working-directory: linux
env:
TAG: ${{ github.ref_name }}
LINUX_BRANCH: ${{ vars.LINUX_BRANCH || 'linux' }}
run: |
git config --global --add safe.directory "$GITHUB_WORKSPACE"
if ! git merge-base --is-ancestor HEAD "origin/$LINUX_BRANCH"; then
echo "::error::$GITHUB_SHA is not on $LINUX_BRANCH"
exit 1
fi
version="$(meson introspect --projectinfo meson.build \
| python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])')"
tag_date="$(TZ=UTC git log -1 --date=format-local:%Y-%m-%d --format=%cd)"
Expand All @@ -57,6 +65,7 @@ jobs:
name: COPR build for Fedora 44
needs: packages
runs-on: ubuntu-24.04
environment: linux-release
timeout-minutes: 300
container:
image: fedora:44
Expand Down Expand Up @@ -84,6 +93,7 @@ jobs:
name: GitHub release
needs: packages
runs-on: ubuntu-24.04
environment: linux-release
timeout-minutes: 15
permissions:
contents: write
Expand All @@ -107,5 +117,6 @@ jobs:
(cd release && sha256sum -- * > SHA256SUMS)
python3 linux/build-aux/packaging/check_packaging.py --source-root linux \
--version "$version" --tag "$TAG" --notes notes.md
gh release create "$TAG" release/* --repo "$GITHUB_REPOSITORY" --verify-tag \
# The repository's latest release stays the macOS one.
gh release create "$TAG" release/* --repo "$GITHUB_REPOSITORY" --verify-tag --latest=false \
--title "TablePro for Linux $version" --notes-file notes.md
2 changes: 1 addition & 1 deletion linux/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ From the repository root, through the verification wrapper, which keeps the full

## CI

`.github/workflows/build-linux.yml` runs on a pull request that touches `linux/`:
`.github/workflows/build-linux.yml` runs on a pull request that touches `linux/` or one of the three Linux workflows:

- **Fast checks**: format, both clippy runs, build, every Meson suite, the Secret Service tests, `meson dist`, then the release-shaped Flatpak manifest rendered from the dist tarball.
- **Supply chain**: `cargo deny check` against `deny.toml`, `cargo shear`, and `build-aux/update-third-party-licenses.sh`, failing when `data/licenses`, `build-aux/debian/copyright` or the spec's `License` tag differ from what it writes; then zizmor over the Linux workflows.
Expand Down
1 change: 1 addition & 0 deletions linux/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion linux/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ Not built yet, in no order.
### Release and project

- Submit to Flathub.
- Create the COPR project, the `COPR_CONFIG` secret and the `LINUX_COPR_PROJECT` variable before the first tag.
- Create the COPR project, the `linux-release` environment limited to `linux-v*` tags with the `COPR_CONFIG` secret, and the `LINUX_COPR_PROJECT` variable before the first tag.
- An AUR PKGBUILD, kept by the community.
- The first translations in `po/LINGUAS`.
- A manual pass with Orca, the keyboard alone, High Contrast, 1.5x text scaling and a right-to-left locale.
Expand Down
18 changes: 16 additions & 2 deletions linux/crates/app/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,25 @@ gtk_env.set('GDK_BACKEND', 'x11')
# gnome.compile_schemas exports GSETTINGS_SCHEMA_DIR to devenv only, so
# the suite points at the build tree itself.
gtk_env.set('GSETTINGS_SCHEMA_DIR', meson.project_build_root() / 'data')
# Inside Flatpak GLib sends notifications through the portal; the suite
# answers them with a freedesktop server of its own on its own bus.
gtk_env.set('GNOTIFICATION_BACKEND', 'freedesktop')

# A session bus of the suite's own, so its names and notifications never
# reach the desktop it runs on, and a build sandbox without a bus still
# has one. A build that skips the tests need not install dbus.
gtk_program = cargo
gtk_args = ['test'] + cargo_options + ['-p', 'tablepro']
dbus_run_session = find_program('dbus-run-session', required: false)
if dbus_run_session.found()
gtk_program = dbus_run_session
gtk_args = ['--', cargo] + gtk_args
endif

test(
'app',
cargo,
args: ['test'] + cargo_options + ['-p', 'tablepro'],
gtk_program,
args: gtk_args,
env: gtk_env,
depends: [compiled_schemas, metainfo_file],
suite: 'gtk',
Expand Down
110 changes: 109 additions & 1 deletion linux/crates/app/src/services/saved_connection_editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,28 @@ use super::secret_labels::secret_label;
/// secrets are deleted. Every write publishes the list, which every
/// picker follows.
///
/// An edit and a copy run one at a time, since each writes the keyring
/// and the list in turn: two edits of one connection from two windows
/// would otherwise leave the endpoint of one with the password of the
/// other.
///
/// Run it on the app runtime, which the Secret Service client needs.
#[derive(Clone)]
pub struct SavedConnectionEditor {
store: ConnectionStore,
secrets: Arc<dyn SecretVault>,
tasks: Tasks,
turn: Arc<futures::lock::Mutex<()>>,
}

impl SavedConnectionEditor {
pub fn new(store: ConnectionStore, secrets: Arc<dyn SecretVault>, tasks: Tasks) -> Self {
Self { store, secrets, tasks }
Self {
store,
secrets,
tasks,
turn: Arc::default(),
}
}

/// Save `connection` over the entry with its id, applying `changes`
Expand All @@ -43,6 +54,7 @@ impl SavedConnectionEditor {
connection: SavedConnection,
changes: SecretChanges,
) -> Result<SavedConnection, SavedConnectionError> {
let _turn = self.turn.lock().await;
let id = connection.id;
let original = self.find(id).await?;
let mut connection = connection;
Expand Down Expand Up @@ -141,6 +153,7 @@ impl SavedConnectionEditor {
/// deleting either leaves the other able to connect. It has never
/// been opened, so it does not claim the original's place at the top.
pub async fn duplicate(&self, id: Uuid) -> Result<SavedConnection, SavedConnectionError> {
let _turn = self.turn.lock().await;
let list = self.list().await?;
let source = list
.iter()
Expand Down Expand Up @@ -319,6 +332,101 @@ mod tests {
.collect()
}

/// A vault whose first SSH password store waits until the test lets
/// it go, so an edit can be held between two of its secrets.
struct HeldVault {
inner: Arc<FakeSecretVault>,
held: futures::lock::Mutex<Option<tokio::sync::oneshot::Receiver<()>>>,
reached: tokio::sync::Notify,
}

#[async_trait::async_trait]
impl SecretVault for HeldVault {
async fn store(
&self,
id: Uuid,
kind: SecretKind,
secret: &SecretString,
label: &str,
) -> Result<(), SecretError> {
if kind == SecretKind::SshPassword {
let gate = self.held.lock().await.take();
if let Some(gate) = gate {
self.reached.notify_one();
gate.await.expect("the test lets the store go");
}
}
self.inner.store(id, kind, secret, label).await
}

async fn load(&self, id: Uuid, kind: SecretKind) -> Result<SecretLookup, SecretError> {
self.inner.load(id, kind).await
}

async fn relabel(&self, id: Uuid, kind: SecretKind, label: &str) -> Result<(), SecretError> {
self.inner.relabel(id, kind, label).await
}

async fn delete_kind(&self, id: Uuid, kind: SecretKind) -> Result<(), SecretError> {
self.inner.delete_kind(id, kind).await
}

async fn delete_connection(&self, id: Uuid) -> Result<(), SecretError> {
self.inner.delete_connection(id).await
}
}

#[tokio::test]
async fn two_edits_of_one_connection_do_not_mix() {
let fixture = fixture().await;
let (release, gate) = tokio::sync::oneshot::channel();
let vault = Arc::new(HeldVault {
inner: fixture.vault.clone(),
held: futures::lock::Mutex::new(Some(gate)),
reached: tokio::sync::Notify::new(),
});
let editor = SavedConnectionEditor::new(fixture.store.clone(), vault.clone(), paused_tasks());
let edit = |host: &str, password: &str| {
let connection = SavedConnection {
host: host.to_owned(),
..fixture.saved.clone()
};
let changes = SecretChanges {
database_password: SecretChange::Replace(SecretString::from(password.to_owned())),
ssh_password: SecretChange::Replace(SecretString::from(password.to_owned())),
ssh_passphrase: SecretChange::Keep,
};
(connection, changes)
};
let (first, first_changes) = edit("first.example.com", "first");
let (second, second_changes) = edit("second.example.com", "second");

let held = editor.update(first, first_changes);
let racing = async {
vault.reached.notified().await;
let second = editor.update(second, second_changes);
let letting_go = async {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
release.send(()).expect("the held store is waiting");
};
futures::future::join(second, letting_go).await.0
};
let (first, second) = futures::future::join(held, racing).await;
first.expect("the first edit");
second.expect("the second edit");

let saved = stored(&fixture.store);
let password = fixture
.vault
.stored_secret(fixture.saved.id, SecretKind::DatabasePassword);
assert_eq!(saved[0].host, "second.example.com");
assert_eq!(
password.as_deref(),
Some("second"),
"the endpoint and password come from different edits"
);
}

#[tokio::test]
async fn duplicate_copies_secrets_to_new_id() {
let fixture = fixture().await;
Expand Down
16 changes: 8 additions & 8 deletions linux/crates/app/src/ui/workspace/health_banner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -548,16 +548,18 @@ mod tests {
/// `[bastion]:2222`, looked up in `known_hosts`, the way an
/// ssh_config with `HostName bastion` would.
fn resolving_ssh(dir: &Path, known_hosts: &Path) -> SshServices {
use std::os::unix::fs::PermissionsExt;

let program = dir.join("ssh");
let resolved = format!(
"hostname bastion\\nport 2222\\nhostkeyalias none\\nuserknownhostsfile {} {}\\n",
known_hosts.display(),
dir.join("known_hosts2").display()
);
std::fs::write(&program, format!("#!/bin/sh\nprintf '{resolved}'\n")).unwrap();
std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap();
tablepro_test_support::write_executable(
&program,
format!("#!/bin/sh\nprintf '{resolved}'\n").as_bytes(),
0o755,
)
.unwrap();
SshServices {
ssh_program: program,
..ssh_services(dir)
Expand Down Expand Up @@ -599,14 +601,12 @@ mod tests {

#[gtk4::test]
fn no_second_review_while_the_old_key_is_removed() {
use std::os::unix::fs::PermissionsExt;

let dir = tempfile::tempdir().unwrap();
let known_hosts = dir.path().join("known_hosts");
std::fs::write(&known_hosts, format!("[bastion]:2222 {KEY}\n")).unwrap();
let slow_keygen = dir.path().join("slow-ssh-keygen");
std::fs::write(&slow_keygen, "#!/bin/sh\nsleep 1\nexec ssh-keygen \"$@\"\n").unwrap();
std::fs::set_permissions(&slow_keygen, std::fs::Permissions::from_mode(0o755)).unwrap();
tablepro_test_support::write_executable(&slow_keygen, b"#!/bin/sh\nsleep 1\nexec ssh-keygen \"$@\"\n", 0o755)
.unwrap();
let ssh = SshServices {
ssh_keygen_program: slow_keygen,
..resolving_ssh(dir.path(), &known_hosts)
Expand Down
7 changes: 3 additions & 4 deletions linux/crates/session/tests/session_postgres.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
reason = "the setup helpers fail a test the way the test functions themselves do"
)]

use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
Expand Down Expand Up @@ -88,12 +87,12 @@ fn isolated_ssh() -> IsolatedSsh {
)
.expect("the ssh config");
let wrapper = dir.path().join("ssh");
std::fs::write(
tablepro_test_support::write_executable(
&wrapper,
format!("#!/bin/sh\nexec ssh -F '{}' \"$@\"\n", config.display()),
format!("#!/bin/sh\nexec ssh -F '{}' \"$@\"\n", config.display()).as_bytes(),
0o755,
)
.expect("the ssh wrapper");
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).expect("an executable wrapper");
let runtime = dir.path().join("runtime");
std::fs::create_dir(&runtime).expect("the runtime directory");
let services = SshServices {
Expand Down
8 changes: 3 additions & 5 deletions linux/crates/ssh/src/known_hosts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,19 +221,17 @@ mod tests {
/// last, the way an ssh_config would resolve it, and fails for any
/// other.
fn fake_ssh(dir: &Path, hosts: &[(&str, String)]) -> PathBuf {
use std::os::unix::fs::PermissionsExt;

let cases: String = hosts
.iter()
.map(|(host, resolved)| format!(" {host}) printf '{resolved}' ;;\n"))
.collect();
let program = dir.join("ssh");
std::fs::write(
tablepro_test_support::write_executable(
&program,
format!("#!/bin/sh\nfor last; do :; done\ncase \"$last\" in\n{cases} *) exit 255 ;;\nesac\n"),
format!("#!/bin/sh\nfor last; do :; done\ncase \"$last\" in\n{cases} *) exit 255 ;;\nesac\n").as_bytes(),
0o755,
)
.unwrap();
std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap();
program
}

Expand Down
4 changes: 1 addition & 3 deletions linux/crates/ssh/src/route.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,6 @@ fn parse_resolved(ssh_g_output: &str) -> Option<Resolved> {

#[cfg(test)]
mod tests {
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;

use super::*;
Expand Down Expand Up @@ -247,8 +246,7 @@ mod tests {

fn fake_ssh(dir: &Path, script: &str) -> PathBuf {
let program = dir.join("ssh");
std::fs::write(&program, format!("#!/bin/sh\n{script}\n")).unwrap();
std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap();
tablepro_test_support::write_executable(&program, format!("#!/bin/sh\n{script}\n").as_bytes(), 0o755).unwrap();
program
}

Expand Down
Loading
Loading