Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
99 changes: 99 additions & 0 deletions .github/workflows/windows-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
name: Build Windows release


on:
workflow_dispatch:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
Comment thread
RambokDev marked this conversation as resolved.
Resolved
branches:
- main
- master

jobs:
build-windows:
runs-on: windows-latest
Comment on lines +4 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Set explicit least-privilege workflow permissions.

The workflow currently relies on default token permissions, which are broader than needed for a release build job. Add a permissions: block at workflow/job scope and grant only what each step needs (e.g., contents: write for release, minimal read elsewhere).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/windows-release.yml around lines 3 - 14, The
windows-release.yml workflow lacks explicit permission restrictions and relies
on default token permissions which are overly broad. Add a `permissions:` block
at the workflow level (after the `on:` section and before the `jobs:` section)
to explicitly define the minimum required permissions for the release build. For
the `build-windows` job, grant only the necessary permissions such as `contents:
write` for creating releases, and use `read` permissions for any other
operations that need minimal access. This ensures the workflow adheres to
least-privilege security principles.

Source: Linters/SAST tools


steps:
- name: Checkout repository
uses: actions/checkout@v4
Comment on lines +18 to +19

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Disable credential persistence in checkout.

actions/checkout should set persist-credentials: false in this release workflow to reduce token exposure surface in subsequent steps/artifacts.

🧰 Tools
🪛 zizmor (1.25.2)

[warning] 17-18: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)


[error] 18-18: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/windows-release.yml around lines 17 - 18, In the
windows-release.yml workflow file, locate the "Checkout repository" step that
uses actions/checkout@v4 and add the persist-credentials input parameter set to
false. This configuration parameter should be added to the uses directive to
prevent the GitHub token from being persisted in the Git configuration, reducing
the security surface area for token exposure in subsequent workflow steps.

Source: Linters/SAST tools

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -name "windows-release.yml" -type f

Repository: Portabase/agent

Length of output: 99


🏁 Script executed:

cat -n ./.github/workflows/windows-release.yml

Repository: Portabase/agent

Length of output: 4256


🌐 Web query:

GitHub Actions security best practices pinning actions to commit SHA

💡 Result:

Pinning GitHub Actions to a full-length commit SHA is a critical security best practice designed to protect against supply chain attacks [1][2][3]. When you reference an action using a branch name or version tag (e.g., @v4 or @main), you are using a mutable reference that can be modified or "retargeted" by the action owner or an attacker who gains control of the repository [1][4][5]. Because tags can be moved to point to new, potentially malicious code, workflows using them are vulnerable to unexpected changes [2][6]. Key security benefits and practices include: Immutability: A full-length commit SHA uniquely identifies a specific state of an action's codebase [3]. Because it is content-addressed, the code cannot be altered once pinned, providing a guarantee that your workflow will execute the exact same logic every time [4][6][3]. Mitigating Supply Chain Attacks: Pinning prevents "tag retargeting" attacks, where malicious code is pushed to an existing tag [2][7]. By using a SHA, you ensure that you are not affected by unauthorized changes to an action's release tags [6][5]. Operational Policies: GitHub supports organization-level policies that can enforce SHA pinning [1][8]. When enabled, workflows attempting to use non-pinned actions will fail, forcing compliance across all repositories in an organization [8][4][2]. Maintenance with Automation: Pinning creates a maintenance burden, as you must manually update SHAs to receive security patches or features [2][5]. To manage this, use automated dependency management tools like Dependabot or Renovate [2][7][6]. These tools can monitor for updates and open pull requests to update your pinned SHAs [2][7][5]. Best Practice for Implementation: - Always use the full 40-character commit SHA [1][5]. - Include the version tag as a comment in your workflow file (e.g., uses: owner/repo@sha # v4.0.0) [2][7]. This makes the dependency human-readable and helps tools like Renovate identify which version to update to [2][5]. - If you maintain shared actions, pin any internal sub-actions to full commit SHAs to prevent breaking the workflows of users who have enforced SHA-pinning policies [4]. - Treat only GitHub-owned actions (under the actions/ namespace) as potentially safe to use with tags, though SHA pinning remains the most secure standard for all dependencies [5].

Citations:


Pin all uses: actions to commit SHAs instead of version tags.

Lines 18, 21, 77, 85, and 93 reference actions by version tag only (e.g., @v4, @v1), which are mutable references vulnerable to tag retargeting attacks. For a release pipeline, use full 40-character commit SHAs to ensure immutability and prevent supply-chain drift. Optionally include the version tag as a comment (e.g., uses: owner/repo@<sha> # v4.0.0) for readability, and use Dependabot or Renovate to automate SHA updates.

🧰 Tools
🪛 zizmor (1.25.2)

[warning] 17-18: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)


[error] 18-18: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/windows-release.yml at line 18, Replace the version tag
references in the `uses:` statements throughout the workflow file with full
40-character commit SHAs instead. Specifically, update the actions/checkout@v4
reference on line 18, and similarly update all other version tag references on
lines 21, 77, 85, and 93 by replacing the `@v` format with the complete commit
SHA. You can optionally append the version tag as a comment after the SHA for
readability (e.g., uses: owner/repo@<40-char-sha> # v4). This ensures
immutability and prevents tag retargeting attacks in the release pipeline.

Source: Linters/SAST tools


- name: Set up Rust toolchain (MSVC)
uses: actions-rs/toolchain@v1
Comment thread
RambokDev marked this conversation as resolved.
Resolved
with:
toolchain: stable-x86_64-pc-windows-msvc
profile: minimal
override: true

- name: Install vcpkg and OpenSSL (x64)
shell: pwsh
run: |
# Install vcpkg and the prebuilt OpenSSL package
git clone https://github.com/microsoft/vcpkg C:\vcpkg
C:\vcpkg\bootstrap-vcpkg.bat
C:\vcpkg\vcpkg install openssl:x64-windows
# Export variables for subsequent steps
'VCPKG_ROOT=C:\vcpkg' | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
'OPENSSL_DIR=C:\vcpkg\installed\x64-windows' | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append

- name: Build (cargo release)
shell: pwsh
env:
# Cargo / openssl-sys will pick up OPENSSL_DIR from the environment
OPENSSL_DIR: ${{ env.OPENSSL_DIR }}
run: |
# Ensure the environment variable is present for this step
if (-Not $env:OPENSSL_DIR) { Write-Host "OPENSSL_DIR not set, printing env for debugging"; Get-ChildItem Env: | ForEach-Object { Write-Host $_ } }
cargo build --release

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make artifact selection deterministic against the declared Rust bin target.

Line 57 prefers portabase-agent.exe, but Cargo.toml declares [[bin]] name = "app". The fallback to “first .exe” can package the wrong executable if multiple binaries exist. Build and package a specific bin explicitly.

Suggested fix
-      - name: Build (cargo release)
+      - name: Build (cargo release)
         shell: pwsh
         env:
           # Cargo / openssl-sys will pick up OPENSSL_DIR from the environment
           OPENSSL_DIR: ${{ env.OPENSSL_DIR }}
         run: |
           # Ensure the environment variable is present for this step
           if (-Not $env:OPENSSL_DIR) { Write-Host "OPENSSL_DIR not set, printing env for debugging"; Get-ChildItem Env: | ForEach-Object { Write-Host $_ } }
-          cargo build --release
+          cargo build --release --bin app
@@
-          # Find the built executable. Prefer an explicit portabase-agent.exe, fall back to first exe in target/release
-          $exe = "target\release\portabase-agent.exe"
-          if (-not (Test-Path $exe)) {
-            $first = Get-ChildItem -Path target\release -Filter *.exe | Select-Object -First 1
-            if ($first) { $exe = $first.FullName }
-          }
+          # Use the explicit release binary declared in Cargo.toml
+          $exe = "target\release\app.exe"

Also applies to: 57-62

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/windows-release.yml at line 46, The cargo build command on
line 46 does not specify an explicit binary target, and the subsequent artifact
selection logic on lines 57-62 attempts to find portabase-agent.exe but falls
back to the first .exe file found, which can be non-deterministic if multiple
binaries exist. Modify the cargo build --release command to explicitly specify
the bin target that is declared in Cargo.toml using the --bin flag, and update
the artifact selection logic on lines 57-62 to match the explicitly built binary
name instead of relying on heuristic fallback matching. This ensures the correct
executable is always packaged regardless of how many binaries exist in the
target directory.


- name: Prepare artifact zip
id: prepare_artifact
shell: pwsh
env:
RELEASE_TAG: ${{ github.ref_name }}
run: |
$tag = $env:RELEASE_TAG
if (-not $tag) { $tag = $env:GITHUB_SHA }

# Find the built executable. Prefer an explicit portabase-agent.exe, fall back to first exe in target/release
$exe = "target\release\portabase-agent.exe"
if (-not (Test-Path $exe)) {
$first = Get-ChildItem -Path target\release -Filter *.exe | Select-Object -First 1
if ($first) { $exe = $first.FullName }
}

if (-not (Test-Path $exe)) { Write-Error "Built binary not found in target/release"; exit 1 }

$outDir = "artifact"
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
Copy-Item -Path $exe -Destination $outDir\

$zipName = "windows-release-$tag.zip"
if (Test-Path $zipName) { Remove-Item $zipName }
Compress-Archive -Path "$outDir\*" -DestinationPath $zipName -Force
Write-Host "ZIP=$zipName"
Write-Output "zip=$zipName" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append

- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: windows-release
path: windows-release-*.zip

- name: Create GitHub Release
if: startsWith(github.ref, 'refs/tags/')
id: create_release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ github.ref_name }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Upload release asset
if: startsWith(github.ref, 'refs/tags/')
uses: actions/upload-release-asset@v1
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: windows-release-${{ github.ref_name }}.zip
asset_name: windows-release-${{ github.ref_name }}.zip
asset_content_type: application/zip
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@
.env

.claude
/docs

/docs
2 changes: 1 addition & 1 deletion CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,5 @@ keywords:
- self-hosted
- portabase
license: Apache-2.0
version: 1.12.1
version: 1.12.2
date-released: '2026-02-24'
2 changes: 1 addition & 1 deletion 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 Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "portabase-agent"
version = "1.12.1"
version = "1.12.2"
edition = "2024"

[dependencies]
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ services:
APP_ENV: development
LOG: debug
TZ: "Europe/Paris"
EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiMGZiNDYyMmUtMTMxNS00MzMxLTlkMTMtZWMzMjAyZjZiNTIwIiwibWFzdGVyS2V5QjY0IjoiMUh0djdtWCtYVkJxL0IzUEV2WDlZZjlQeUdVZW5oRHlXemo5THRqNW90WT0ifQ=="
EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiNWRkZTE1NTctZWQ1ZC00MjUxLThiZDMtMDE0MjkxOTg2OGZjIiwibWFzdGVyS2V5QjY0IjoiQlhWM1hvbEM2NTZTVjdkTmdjV1BHUWxrKytycExJNmxHRGk3Q1BCNWllbz0ifQ=="
Comment thread
RambokDev marked this conversation as resolved.
Resolved
#CHUNK_SIZE_MB: "1"
#POOLING: 1
#DATABASES_CONFIG_FILE: "config.toml"
Expand Down
24 changes: 13 additions & 11 deletions src/domain/postgres/backup.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use anyhow::Result;
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Command;
use std::sync::Arc;
Expand All @@ -13,6 +14,7 @@ pub async fn run(
cfg: DatabaseConfig,
format: PostgresDumpFormat,
backup_dir: PathBuf,
env: HashMap<String, String>,
logger: Arc<JobLogger>,
) -> Result<PathBuf> {
tokio::task::spawn_blocking(move || -> Result<PathBuf> {
Expand All @@ -37,18 +39,18 @@ pub async fn run(
logger.log("info", format!("Running FC backup for {}", cfg.name));

let file_path = backup_dir.join(format!("{}.dump", cfg.generated_id));
let url = format!(
"postgresql://{}:{}@{}:{}/{}",
cfg.username, cfg.password, cfg.host, cfg.port, cfg.database
);

let start = Instant::now();
let output = Command::new(&pg_dump)
.arg("--dbname").arg(&url)
.arg("--host").arg(&cfg.host)
.arg("--port").arg(cfg.port.to_string())
.arg("--username").arg(&cfg.username)
.arg("--dbname").arg(&cfg.database)
.arg("-Fc")
.arg("-f").arg(&file_path)
.arg("-v")
.arg("--compress=3")
.envs(env)
.output();
let duration_ms = start.elapsed().as_millis() as f64;

Expand Down Expand Up @@ -87,19 +89,19 @@ pub async fn run(
return Err(e.into());
}

let url = format!(
"postgresql://{}:{}@{}:{}/{}",
cfg.username, cfg.password, cfg.host, cfg.port, cfg.database
);
let cmd_label = format!("pg_dump -Fd {}", url);
let cmd_label = format!("pg_dump -Fd {}@{}:{}/{}", cfg.username, cfg.host, cfg.port, cfg.database);

let start = Instant::now();
let output = Command::new(&pg_dump)
.arg("--dbname").arg(&url)
.arg("--host").arg(&cfg.host)
.arg("--port").arg(cfg.port.to_string())
.arg("--username").arg(&cfg.username)
.arg("--dbname").arg(&cfg.database)
.arg("-Fd")
.arg("-j").arg("4")
.arg("-f").arg(&dump_dir)
.arg("-v")
.envs(env)
.output();
let duration_ms = start.elapsed().as_millis() as f64;

Expand Down
98 changes: 92 additions & 6 deletions src/domain/postgres/connection.rs
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
use crate::domain::postgres::format::PostgresDumpFormat;
use crate::services::config::DatabaseConfig;
use crate::settings::CONFIG;
use anyhow::Result;
use std::path::Path;
use tokio_postgres::{Client, NoTls};
use tokio_postgres::{Client, Config, NoTls};
use tracing::{error, info};

pub async fn connect(cfg: &DatabaseConfig) -> Result<Client> {
info!("Connecting to postgres database {}:{}", cfg.host, cfg.port);
let dsn = format!(
"host={} port={} user={} password={} dbname={}",
cfg.host, cfg.port, cfg.username, cfg.password, cfg.database
);

let (client, connection) = tokio_postgres::connect(&dsn, NoTls).await?;
let mut config = Config::new();
config
.host(&cfg.host)
.port(cfg.port)
.user(&cfg.username)
.password(&cfg.password)
.dbname(&cfg.database);

let (client, connection) = config.connect(NoTls).await?;
tokio::spawn(async move {
if let Err(e) = connection.await {
error!("Postgres connection error: {}", e);
Expand All @@ -28,11 +33,91 @@ pub async fn server_version(cfg: &DatabaseConfig) -> Result<String> {
Ok(version)
}

/// Resolves the `bin` directory of a PostgreSQL installation for the given
/// major version, in a cross-platform way.
///
/// Resolution order:
/// 1. The `PG_BIN_DIR` environment variable, if set, is used as-is. This
/// allows users/CI to override detection for non-standard installs
/// (e.g. portable PostgreSQL distributions, custom install locations).
/// 2. Platform-specific default install locations (Debian/Ubuntu packages,
/// the official Windows installer, Homebrew/Postgres.app on macOS, and
/// common RPM-based layouts on other Linux distros).
/// 3. A `PATH` lookup for `pg_dump` (`pg_dump.exe` on Windows), returning
/// its parent directory.
/// 4. The historical Debian/Ubuntu path as a last-resort fallback, so the
/// function keeps returning a `PathBuf` (never panics) even when nothing
/// was found, preserving the previous behavior for callers.
///
/// The override is sourced from `CONFIG.pg_bin_dir` (the `PG_BIN_DIR`
/// environment variable). An empty value means "unset" and falls through to
/// detection.
pub fn select_pg_path(version: &str) -> std::path::PathBuf {
select_pg_path_with(version, &CONFIG.pg_bin_dir)
}

/// Inner resolver behind [`select_pg_path`], parameterized over the
/// `PG_BIN_DIR` override. Kept pure (no env / no `CONFIG` access) so it is
/// unit-testable without mutating process-global state.
pub(crate) fn select_pg_path_with(version: &str, pg_bin_dir: &str) -> std::path::PathBuf {
let major = version.split('.').next().unwrap_or("17");

if !pg_bin_dir.is_empty() {
return pg_bin_dir.into();
}

let candidates: Vec<std::path::PathBuf> = if cfg!(target_os = "windows") {
vec![
// Default install path used by the official EDB Windows installer
format!(r"C:\Program Files\PostgreSQL\{major}\bin").into(),
format!(r"C:\Program Files (x86)\PostgreSQL\{major}\bin").into(),
]
} else if cfg!(target_os = "macos") {
vec![
// Homebrew on Apple Silicon
format!("/opt/homebrew/opt/postgresql@{major}/bin").into(),
// Homebrew on Intel
format!("/usr/local/opt/postgresql@{major}/bin").into(),
// Postgres.app
format!("/Applications/Postgres.app/Contents/Versions/{major}/bin").into(),
]
} else {
vec![
// Debian/Ubuntu packages
format!("/usr/lib/postgresql/{major}/bin").into(),
// Common RPM-based distro layout
format!("/usr/pgsql-{major}/bin").into(),
]
};

if let Some(found) = candidates.into_iter().find(|p| pg_dump_exists_in(p)) {
return found;
}

if let Some(dir) = find_pg_dump_in_path() {
return dir;
}

format!("/usr/lib/postgresql/{}/bin", major).into()
}

pub(crate) fn pg_dump_binary_name() -> &'static str {
if cfg!(target_os = "windows") {
"pg_dump.exe"
} else {
"pg_dump"
}
}

pub(crate) fn pg_dump_exists_in(dir: &std::path::Path) -> bool {
dir.join(pg_dump_binary_name()).is_file()
}

fn find_pg_dump_in_path() -> Option<std::path::PathBuf> {
let path_var = std::env::var_os("PATH")?;
std::env::split_paths(&path_var).find(|dir| pg_dump_exists_in(dir))
}

pub async fn terminate_connections(cfg: &DatabaseConfig) -> Result<()> {
let mut admin = cfg.clone();
admin.database = "postgres".to_string().into();
Expand Down Expand Up @@ -68,6 +153,7 @@ pub async fn detect_format_from_size(cfg: &DatabaseConfig) -> PostgresDumpFormat
"Detecting database format {:?} - {:?}",
cfg.name, cfg.generated_id
);

let client = match connect(cfg).await {
Ok(c) => c,
Err(_) => return PostgresDumpFormat::Fc,
Expand Down
28 changes: 25 additions & 3 deletions src/domain/postgres/database.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
use anyhow::Result;
use async_trait::async_trait;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use tracing::info;
use super::{backup, format::PostgresDumpFormat, ping, restore};
use crate::domain::factory::Database;
use crate::services::backup::logger::JobLogger;
Expand All @@ -18,6 +19,13 @@ impl PostgresDatabase {
pub fn new(cfg: DatabaseConfig, format: PostgresDumpFormat) -> Self {
Self { cfg, format }
}

fn build_env(&self) -> HashMap<String, String> {
let mut envs = std::env::vars().collect::<HashMap<_, _>>();
envs.insert("PGPASSWORD".to_string(), self.cfg.password.to_string());
info!("envs: {:?}", envs);
envs
}
}

#[async_trait]
Expand All @@ -35,14 +43,28 @@ impl Database for PostgresDatabase {

async fn backup(&self, dir: &Path, logger: Arc<JobLogger>) -> Result<PathBuf> {
FileLock::acquire(&self.cfg.generated_id, DbOpLock::Backup.as_str()).await?;
let res = backup::run(self.cfg.clone(), self.format, dir.to_path_buf(), logger).await;
let res = backup::run(
self.cfg.clone(),
self.format,
dir.to_path_buf(),
self.build_env(),
logger,
)
.await;
FileLock::release(&self.cfg.generated_id).await?;
res
}

async fn restore(&self, file: &Path, logger: Arc<JobLogger>) -> Result<()> {
FileLock::acquire(&self.cfg.generated_id, DbOpLock::Restore.as_str()).await?;
let res = restore::run(self.cfg.clone(), self.format, file.to_path_buf(), logger).await;
let res = restore::run(
self.cfg.clone(),
self.format,
file.to_path_buf(),
self.build_env(),
logger,
)
.await;
FileLock::release(&self.cfg.generated_id).await?;
res
}
Expand Down
2 changes: 1 addition & 1 deletion src/domain/postgres/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
pub mod backup;
mod connection;
pub(crate) mod connection;
pub mod database;
mod format;
mod ping;
Expand Down
Loading