diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 8512df9f2a..c4da1cf3dd 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -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: diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml index 502d68b0d4..3a07a1ef5b 100644 --- a/.github/workflows/release-linux.yml +++ b/.github/workflows/release-linux.yml @@ -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: @@ -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)" @@ -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 @@ -84,6 +93,7 @@ jobs: name: GitHub release needs: packages runs-on: ubuntu-24.04 + environment: linux-release timeout-minutes: 15 permissions: contents: write @@ -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 diff --git a/linux/AGENTS.md b/linux/AGENTS.md index ada224bebb..f86d1ae1ce 100644 --- a/linux/AGENTS.md +++ b/linux/AGENTS.md @@ -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. diff --git a/linux/Cargo.lock b/linux/Cargo.lock index 5ba98a4706..5a1af6ffb2 100644 --- a/linux/Cargo.lock +++ b/linux/Cargo.lock @@ -4649,6 +4649,7 @@ version = "0.0.0" dependencies = [ "rcgen", "rustls", + "tablepro-test-support", "tempfile", "testcontainers", "testcontainers-modules", diff --git a/linux/ROADMAP.md b/linux/ROADMAP.md index 48e1ec90b5..46a5d2e3ab 100644 --- a/linux/ROADMAP.md +++ b/linux/ROADMAP.md @@ -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. diff --git a/linux/crates/app/meson.build b/linux/crates/app/meson.build index dedb9aa072..d9162c681c 100644 --- a/linux/crates/app/meson.build +++ b/linux/crates/app/meson.build @@ -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', diff --git a/linux/crates/app/src/services/saved_connection_editor.rs b/linux/crates/app/src/services/saved_connection_editor.rs index 7ba0c4eaed..e34d2e9059 100644 --- a/linux/crates/app/src/services/saved_connection_editor.rs +++ b/linux/crates/app/src/services/saved_connection_editor.rs @@ -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, tasks: Tasks, + turn: Arc>, } impl SavedConnectionEditor { pub fn new(store: ConnectionStore, secrets: Arc, tasks: Tasks) -> Self { - Self { store, secrets, tasks } + Self { + store, + secrets, + tasks, + turn: Arc::default(), + } } /// Save `connection` over the entry with its id, applying `changes` @@ -43,6 +54,7 @@ impl SavedConnectionEditor { connection: SavedConnection, changes: SecretChanges, ) -> Result { + let _turn = self.turn.lock().await; let id = connection.id; let original = self.find(id).await?; let mut connection = connection; @@ -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 { + let _turn = self.turn.lock().await; let list = self.list().await?; let source = list .iter() @@ -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, + held: futures::lock::Mutex>>, + 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 { + 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; diff --git a/linux/crates/app/src/ui/workspace/health_banner.rs b/linux/crates/app/src/ui/workspace/health_banner.rs index d419413f3b..e1d3100f2e 100644 --- a/linux/crates/app/src/ui/workspace/health_banner.rs +++ b/linux/crates/app/src/ui/workspace/health_banner.rs @@ -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) @@ -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) diff --git a/linux/crates/session/tests/session_postgres.rs b/linux/crates/session/tests/session_postgres.rs index 0a8693e628..a794a2f7b8 100644 --- a/linux/crates/session/tests/session_postgres.rs +++ b/linux/crates/session/tests/session_postgres.rs @@ -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; @@ -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 { diff --git a/linux/crates/ssh/src/known_hosts.rs b/linux/crates/ssh/src/known_hosts.rs index 0e5fe83a2d..00c51aac71 100644 --- a/linux/crates/ssh/src/known_hosts.rs +++ b/linux/crates/ssh/src/known_hosts.rs @@ -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 } diff --git a/linux/crates/ssh/src/route.rs b/linux/crates/ssh/src/route.rs index 2d0f5393f4..9d772a1032 100644 --- a/linux/crates/ssh/src/route.rs +++ b/linux/crates/ssh/src/route.rs @@ -170,7 +170,6 @@ fn parse_resolved(ssh_g_output: &str) -> Option { #[cfg(test)] mod tests { - use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use super::*; @@ -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 } diff --git a/linux/crates/ssh/tests/openssh_session.rs b/linux/crates/ssh/tests/openssh_session.rs index 7ecd9ec397..295e7aaeb8 100644 --- a/linux/crates/ssh/tests/openssh_session.rs +++ b/linux/crates/ssh/tests/openssh_session.rs @@ -59,11 +59,11 @@ async fn start() -> TestResult { let config = temp.path().join("ssh_config"); write_config(&config, &known_hosts, "ask")?; let wrapper = temp.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, )?; - std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755))?; let runtime_dir = temp.path().join("runtime"); std::fs::create_dir(&runtime_dir)?; @@ -441,16 +441,17 @@ async fn a_jump_host_whose_user_config_skips_the_host_key_check_still_asks() { // An Include refuses a file others can write. std::fs::set_permissions(&user_config, std::fs::Permissions::from_mode(0o600)).unwrap(); let wrapper = fixture.temp.path().join("ssh-as-user"); - std::fs::write( + tablepro_test_support::write_executable( &wrapper, format!( "#!/bin/sh\nHOME='{}' exec ssh -F '{}' \"$@\"\n", home.display(), user_config.display() - ), + ) + .as_bytes(), + 0o755, ) .unwrap(); - std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap(); let services = SshServices { ssh_program: wrapper, config_file: None, diff --git a/linux/crates/storage/src/connection_group_name.rs b/linux/crates/storage/src/connection_group_name.rs index 95bf74d660..1299a581fd 100644 --- a/linux/crates/storage/src/connection_group_name.rs +++ b/linux/crates/storage/src/connection_group_name.rs @@ -1,12 +1,13 @@ -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use crate::group_name_error::GroupNameError; /// The group a saved connection is filed under, as the user typed it /// without the spaces around it. /// -/// Read from disk through the same check as from the dialog, so a name -/// a menu or a section title could not show never reaches either. +/// A name read from disk passes the same check as one from the dialog, +/// after `from_stored` cleans it, so a name a menu or a section title +/// could not show never reaches either. #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] #[serde(try_from = "String", into = "String")] pub struct ConnectionGroupName(String); @@ -29,11 +30,30 @@ impl ConnectionGroupName { Ok(Self(name.to_owned())) } + /// A name read back from a file. Builds before the limits stored any + /// name the user typed, so control characters become spaces and the + /// name is cut to the limit rather than making the file unreadable. + /// A blank name means no group. + pub(crate) fn from_stored(value: &str) -> Option { + let shown: String = value.chars().map(|c| if c.is_control() { ' ' } else { c }).collect(); + let cut: String = shown.trim().chars().take(Self::MAX_CHARS).collect(); + Self::new(&cut).ok() + } + pub fn as_str(&self) -> &str { &self.0 } } +/// Reads a saved connection's `group` through `from_stored`. +pub(crate) fn deserialize_stored<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let stored = Option::::deserialize(deserializer)?; + Ok(stored.as_deref().and_then(ConnectionGroupName::from_stored)) +} + impl TryFrom for ConnectionGroupName { type Error = GroupNameError; @@ -85,6 +105,21 @@ mod tests { assert_eq!(ConnectionGroupName::new(&name).map(String::from), Ok(name)); } + #[test] + fn a_stored_name_is_cleaned_rather_than_refused() { + let long = "a".repeat(ConnectionGroupName::MAX_CHARS + 10); + assert_eq!( + ConnectionGroupName::from_stored(&long).map(String::from), + Some("a".repeat(ConnectionGroupName::MAX_CHARS)) + ); + assert_eq!( + ConnectionGroupName::from_stored("Work\tHome\n").map(String::from), + Some(String::from("Work Home")) + ); + assert_eq!(ConnectionGroupName::from_stored(" \t "), None); + assert_eq!(ConnectionGroupName::from_stored(""), None); + } + #[test] fn group_name_try_from_and_into_string_round_trip() { let name = ConnectionGroupName::try_from(String::from(" Prod ")).expect("a valid name"); diff --git a/linux/crates/storage/src/connections.rs b/linux/crates/storage/src/connections.rs index 968c0e0f12..e94efb4b73 100644 --- a/linux/crates/storage/src/connections.rs +++ b/linux/crates/storage/src/connections.rs @@ -43,7 +43,11 @@ pub struct SavedConnection { pub color: Option, /// The folder the user filed this connection under. `None` means it /// sits on its own, which is where every connection starts. - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "crate::connection_group_name::deserialize_stored" + )] pub group: Option, } @@ -1210,18 +1214,33 @@ mod tests { serde_json::from_slice(&std::fs::read(opened.path()).expect("read")).expect("json"); assert_eq!(raw["connections"][0]["group"], "Work"); - for blank in [r#""""#, r#"" ""#] { + for blank in [r#""""#, r#"" ""#, "null"] { let root = TempDir::new().expect("tempdir"); seed(&root, &connections_json(Some(blank))); - let error = store(&root).load_blocking().expect_err("a blank group"); + assert_eq!(store(&root).load_blocking().expect(blank)[0].group, None, "{blank}"); + } + } - let StorageError::DocumentUnavailable(problem) = error else { - panic!("expected a document problem, got {error:?}"); - }; - assert!( - problem.to_string().contains(&crate::GroupNameError::Empty.to_string()), - "{blank}: {problem}" + #[test] + fn a_group_an_earlier_build_wrote_past_the_limits_still_loads() { + let long = "g".repeat(crate::ConnectionGroupName::MAX_CHARS + 1); + for (stored, read) in [ + ( + format!(r#""{long}""#), + "g".repeat(crate::ConnectionGroupName::MAX_CHARS), + ), + (String::from(r#""Work\tHome""#), String::from("Work Home")), + ] { + let root = TempDir::new().expect("tempdir"); + seed(&root, &connections_json(Some(&stored))); + + let list = store(&root).load_blocking().expect("the list loads"); + + assert_eq!( + list[0].group.as_ref().map(crate::ConnectionGroupName::as_str), + Some(read.as_str()), + "{stored}" ); } } diff --git a/linux/crates/test-fixtures/Cargo.toml b/linux/crates/test-fixtures/Cargo.toml index b24add33b5..9a5455c72c 100644 --- a/linux/crates/test-fixtures/Cargo.toml +++ b/linux/crates/test-fixtures/Cargo.toml @@ -11,6 +11,7 @@ path = "src/lib.rs" [dependencies] rcgen.workspace = true +tablepro-test-support = { path = "../test-support" } tempfile.workspace = true testcontainers.workspace = true testcontainers-modules = { workspace = true, features = ["postgres", "mssql_server"] } diff --git a/linux/crates/test-fixtures/src/scripted_askpass.rs b/linux/crates/test-fixtures/src/scripted_askpass.rs index 1bd6a5d59b..918777c621 100644 --- a/linux/crates/test-fixtures/src/scripted_askpass.rs +++ b/linux/crates/test-fixtures/src/scripted_askpass.rs @@ -1,6 +1,3 @@ -use std::fs::OpenOptions; -use std::io::Write; -use std::os::unix::fs::OpenOptionsExt; use std::path::{Path, PathBuf}; use tempfile::TempDir; @@ -18,13 +15,8 @@ impl ScriptedAskpass { pub fn new() -> Result { let dir = tempfile::tempdir()?; let path = dir.path().join("askpass"); - let mut script = OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o700) - .open(&path)?; - script.write_all(format!("#!/bin/sh\nprintf '%s\\n' \"${}\"\n", Self::ANSWER_VARIABLE).as_bytes())?; - script.sync_all()?; + let script = format!("#!/bin/sh\nprintf '%s\\n' \"${}\"\n", Self::ANSWER_VARIABLE); + tablepro_test_support::write_executable(&path, script.as_bytes(), 0o700)?; Ok(Self { _dir: dir, path }) } @@ -45,24 +37,10 @@ mod tests { let askpass = ScriptedAskpass::new().unwrap(); let mode = std::fs::metadata(askpass.path()).unwrap().permissions().mode() & 0o777; - // A test thread that forks while the script is still open for - // writing hands its child the write handle until that child - // execs, and exec refuses a file open for writing. - let output = (0..100) - .find_map(|_| { - match Command::new(askpass.path()) - .arg("tablepro@localhost's password: ") - .env(ScriptedAskpass::ANSWER_VARIABLE, "s3cret value") - .output() - { - Err(error) if error.kind() == std::io::ErrorKind::ExecutableFileBusy => { - std::thread::sleep(std::time::Duration::from_millis(10)); - None - } - ran => Some(ran), - } - }) - .expect("the script was busy on every try") + let output = Command::new(askpass.path()) + .arg("tablepro@localhost's password: ") + .env(ScriptedAskpass::ANSWER_VARIABLE, "s3cret value") + .output() .unwrap(); assert_eq!(mode, 0o700); diff --git a/linux/crates/test-support/src/executable.rs b/linux/crates/test-support/src/executable.rs new file mode 100644 index 0000000000..095d5168c6 --- /dev/null +++ b/linux/crates/test-support/src/executable.rs @@ -0,0 +1,52 @@ +use std::io::{self, Write}; +use std::path::Path; +use std::process::Command; + +/// Write `contents` to `path` as a program with permission bits `mode`. +/// +/// Linux refuses to run a file any process holds open for writing +/// (ETXTBSY), and a child that another test thread forks while this +/// process has the file open keeps that handle until the child execs. +/// So `install` writes the program from a staged copy: this process +/// never holds `path` open for writing, and no fork can carry it off. +pub fn write_executable(path: &Path, contents: &[u8], mode: u32) -> io::Result<()> { + let dir = path.parent().unwrap_or_else(|| Path::new(".")); + let mut staged = tempfile::NamedTempFile::new_in(dir)?; + staged.write_all(contents)?; + staged.flush()?; + let status = Command::new("install") + .arg("-m") + .arg(format!("{mode:o}")) + .arg(staged.path()) + .arg(path) + .status()?; + if !status.success() { + return Err(io::Error::other(format!("install exited with {status}"))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::os::unix::fs::PermissionsExt; + + use super::*; + + #[test] + fn the_program_runs_at_once_with_its_mode() { + let dir = tempfile::tempdir().unwrap(); + let program = dir.path().join("hello"); + + write_executable(&program, b"#!/bin/sh\necho hello\n", 0o700).unwrap(); + + let mode = std::fs::metadata(&program).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700); + let output = Command::new(&program).output().unwrap(); + assert_eq!(output.stdout, b"hello\n"); + assert_eq!( + std::fs::read_dir(dir.path()).unwrap().count(), + 1, + "the staged copy was left behind" + ); + } +} diff --git a/linux/crates/test-support/src/lib.rs b/linux/crates/test-support/src/lib.rs index 63dfdf4552..6e6ba8a9af 100644 --- a/linux/crates/test-support/src/lib.rs +++ b/linux/crates/test-support/src/lib.rs @@ -12,6 +12,7 @@ mod close_behaviour; mod close_race; pub mod conformance; mod connection_script; +mod executable; mod fake_connection; mod fake_dialect; mod fake_driver; @@ -28,6 +29,7 @@ mod stream_end; pub use call_counters::CallCounters; pub use close_behaviour::CloseBehaviour; pub use connection_script::ConnectionScript; +pub use executable::write_executable; pub use fake_connection::FakeConnection; pub use fake_dialect::FakeDialect; pub use fake_driver::FakeDriver; diff --git a/linux/docs/decisions/0017-distribution-channels.md b/linux/docs/decisions/0017-distribution-channels.md index 629e7b2b27..9899819a03 100644 --- a/linux/docs/decisions/0017-distribution-channels.md +++ b/linux/docs/decisions/0017-distribution-channels.md @@ -27,9 +27,12 @@ Three channels, all built from one `meson install` and one lock file: | .deb for Ubuntu 26.04 | `dpkg-buildpackage -b` with the committed `build-aux/debian`, Rust from rustup | GitHub Releases | | .rpm for Fedora 44 | `rpmbuild` with the committed `build-aux/rpm/tablepro.spec` and Fedora's Rust | COPR | -A `linux-vX.Y.Z` tag runs `release-linux.yml`. It checks that the tag, -`meson.build`, the newest metainfo release (dated the day of the tagged -commit), the spec and `CHANGELOG.md` name one version, then publishes: +A `linux-vX.Y.Z` tag runs `release-linux.yml`. It checks that the tagged +commit is on the `LINUX_BRANCH` branch (`linux` when unset) and that the +tag, `meson.build`, the newest metainfo release (dated the day of the +tagged commit), the spec and `CHANGELOG.md` name one version, then +publishes, under the `linux-release` environment and without taking the +repository's latest release from macOS: - `tablepro-X.Y.Z.tar.xz` from `meson dist`; - `tablepro-X.Y.Z-vendor.tar.xz`: `vendor/` from `cargo vendor --locked @@ -83,9 +86,10 @@ Meson test fails when they drift. `-f %{name}.lang` in the spec; the same test fails until it is there. - A pre-release version such as `0.2.0-rc.1` cannot be packaged: RPM forbids `-` in `Version` and git forbids `~` in a tag. -- Publishing needs the `COPR_CONFIG` secret and the `LINUX_COPR_PROJECT` - variable; without them the COPR job fails and the GitHub release still - goes out. +- Publishing needs the `linux-release` environment, limited to `linux-v*` + tags and holding the `COPR_CONFIG` secret, and the `LINUX_COPR_PROJECT` + variable; without the secret or the variable the COPR job fails and the + GitHub release still goes out. - The vendor tarball holds every crate in the lock file, Windows-only and test-only ones included, because `cargo vendor` cannot filter. - The release profile's fat LTO needs gigabytes per binary. The .deb diff --git a/linux/docs/storage.md b/linux/docs/storage.md index 05dec64b8e..c75dd06712 100644 --- a/linux/docs/storage.md +++ b/linux/docs/storage.md @@ -22,7 +22,7 @@ The app's data goes to the store its shape calls for. The `storage` crate owns t - `subscribe` hands out a watch of `ConnectionListSnapshot`, so every picker sees every writer's change. - A file that cannot be read, or that another version wrote, leaves the store `Unavailable`, and it refuses every write until the user picks Reset (`reset_unreadable_blocking`), which moves the file aside. - A `SavedConnection` does **not** carry a password. Secrets live in the keyring, keyed by the connection's UUID, so a rename or a new host keeps them. -- `group` is a `ConnectionGroupName`: trimmed, 1 to 64 characters, no control characters. A file with any other group is refused like any other unreadable list. +- `group` is a `ConnectionGroupName`: trimmed, 1 to 64 characters, no control characters. Earlier builds stored any name, so a stored one is read with its control characters turned into spaces and cut to 64 characters, and a blank one reads as no group. - An edit, a copy or a move to another group goes through the app's `SavedConnectionEditor`, which writes the keyring first. A refused secret stops before the list changes, and the secrets of a copy whose entry did not land are deleted again. ### SSH settings