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
7 changes: 7 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ on:

permissions:
contents: write
packages: none
issues: none
pull-requests: none

jobs:
build-and-release:
Expand All @@ -28,8 +31,12 @@ jobs:
- name: Create artifact bundle
run: ./Scripts/release-artifactbundle.sh "${{ env.TAG_NAME }}"

- name: Generate checksums
run: shasum -a 256 xpbc-macos.artifactbundle.zip > checksums.txt

- name: Upload release assets
uses: softprops/action-gh-release@v2
with:
files: |
xpbc-macos.artifactbundle.zip
checksums.txt
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ make install PREFIX=~/.local
## Usage

```
xpbc [-pboard {general|ruler|find|font}] [--help] [--version]
xpbc [-pboard {general|ruler|find|font}] [--no-validate] [--help] [--version]
```

Pipe any data into `xpbc` via stdin. It automatically detects the format and copies accordingly.
Expand Down Expand Up @@ -90,14 +90,15 @@ Anything that doesn't match a known image signature is copied as text.
| Code | Meaning |
|------|---------|
| 0 | Success |
| 1 | Known error (empty input, input too large, invalid argument, pasteboard write failure) |
| 1 | Known error (empty input, input too large, invalid argument, validation failure, pasteboard write failure) |
| 2 | Unexpected error |

### Options

| Flag | Description |
|------|-------------|
| `-pboard NAME` | Target pasteboard: `general` (default), `ruler`, `find`, or `font` |
| `--no-validate` | Skip structural validation of image headers |
| `-h`, `--help` | Print usage |
| `-v`, `--version` | Print version |

Expand All @@ -118,6 +119,17 @@ make clean # Clean build artifacts
- Input size is capped at 100 MB (read in 64 KB chunks to prevent OOM)
- stdin-only input (no file path arguments, no path traversal risk)
- Written in memory-safe Swift with no `Unsafe` pointer usage
- Structural validation of image headers is enabled by default (use `--no-validate` to skip)

### Important limitations

**xpbc does not guarantee the safety of clipboard contents.** While structural validation checks that image headers are well-formed, it cannot detect:

- **Crafted exploit payloads** — A structurally valid image (valid headers, correct dimensions) can still contain malicious data that exploits vulnerabilities in the application where you paste it (e.g., heap overflows in image decoders like libwebp, ImageIO).
- **Decompression bombs** — An image with valid headers but compressed data that expands to an extreme size, causing the paste target to crash with out-of-memory.
- **PDF active content** — While xpbc blocks PDFs containing known dangerous keywords (`/JS`, `/JavaScript`, `/OpenAction`, `/AA`, `/Launch`), obfuscated or novel techniques may bypass this check.

**Do not pipe untrusted data** (e.g., `curl <untrusted-url> | xpbc`) **without understanding the risk.** The clipboard contents will be processed by whatever application you paste into, and xpbc cannot protect against vulnerabilities in those applications.

## Architecture

Expand Down
37 changes: 32 additions & 5 deletions Scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,52 @@ set -euo pipefail
REPO="chigichan24/xpbc"
ASSET_NAME="xpbc-macos.artifactbundle.zip"
ASSET_URL="https://github.com/$REPO/releases/latest/download/$ASSET_NAME"
CHECKSUM_URL="https://github.com/$REPO/releases/latest/download/checksums.txt"
INSTALL_DIR="${1:-$HOME/.local/bin}"

# Clean up intermediate files on any exit
cleanup() {
rm -f "$ASSET_NAME" checksums.txt
rm -rf extracted_files
}
trap cleanup EXIT

# Validate install directory
case "$INSTALL_DIR" in
/*) ;;
*) echo "Error: install directory must be an absolute path: $INSTALL_DIR" >&2; exit 1 ;;
esac
case "$INSTALL_DIR" in
*..*) echo "Error: install directory must not contain '..': $INSTALL_DIR" >&2; exit 1 ;;
esac

# Download zip file
echo "Downloading latest xpbc..."
curl -sL -o "$ASSET_NAME" "$ASSET_URL"
curl -fsSL -o "$ASSET_NAME" "$ASSET_URL"
curl -fsSL -o checksums.txt "$CHECKSUM_URL"

# Verify checksum
echo "Verifying checksum..."
shasum -a 256 -c checksums.txt --ignore-missing || {
echo "Error: checksum verification failed!" >&2
exit 1
}

unzip -qo "$ASSET_NAME" -d extracted_files
rm "$ASSET_NAME"

VERSION=$(ls ./extracted_files/xpbc.artifactbundle | sed -n 's/^xpbc-\([^-]*\)-macos$/\1/p' | head -n 1)
if [ -z "$VERSION" ]; then
echo "Error: version not found in the artifact bundle."
rm -rf extracted_files
echo "Error: version not found in the artifact bundle." >&2
exit 1
fi
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Error: unexpected version format: $VERSION" >&2
exit 1
fi

mkdir -p "$INSTALL_DIR"
cp -f "./extracted_files/xpbc.artifactbundle/xpbc-$VERSION-macos/bin/xpbc" "$INSTALL_DIR/xpbc"
chmod +x "$INSTALL_DIR/xpbc"
rm -rf extracted_files

echo "Installed xpbc $VERSION to $INSTALL_DIR/xpbc"
echo "Please make sure $INSTALL_DIR is in your \$PATH"
8 changes: 7 additions & 1 deletion Scripts/release-artifactbundle.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,15 @@ set -euo pipefail

VERSION_STRING="$1"

# Validate version format
if ! echo "$VERSION_STRING" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Error: invalid version format '$VERSION_STRING' (expected vX.Y.Z)" >&2
exit 1
fi

mkdir -p "xpbc.artifactbundle/xpbc-$VERSION_STRING-macos/bin"

sed "s/__VERSION__/$VERSION_STRING/g" ./Scripts/info.json > "xpbc.artifactbundle/info.json"
sed "s|__VERSION__|$VERSION_STRING|g" ./Scripts/info.json > "xpbc.artifactbundle/info.json"

cp -f "./.build/apple/Products/Release/xpbc" "xpbc.artifactbundle/xpbc-$VERSION_STRING-macos/bin"

Expand Down
26 changes: 26 additions & 0 deletions Sources/XPBCCore/DataValidator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import Foundation

public struct DataValidator: Sendable {
public static func validate(_ data: Data, as type: DataType) -> ValidationResult {
switch type {
case .text:
return .valid
case .png:
return PNGValidator().validate(data)
case .jpeg:
return JPEGValidator().validate(data)
case .gif:
return GIFValidator().validate(data)
case .tiff:
return TIFFValidator().validate(data)
case .bmp:
return BMPValidator().validate(data)
case .webp:
return WebPValidator().validate(data)
case .heic, .avif:
return FtypValidator().validate(data)
case .pdf:
return PDFValidator().validate(data)
}
}
}
17 changes: 16 additions & 1 deletion Sources/XPBCCore/FormatDetector.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Foundation

public enum DataType: Equatable, Sendable {
public enum DataType: Equatable, Sendable, CustomStringConvertible {
case png
case jpeg
case gif
Expand All @@ -11,6 +11,21 @@ public enum DataType: Equatable, Sendable {
case avif
case pdf
case text

public var description: String {
switch self {
case .png: return "PNG"
case .jpeg: return "JPEG"
case .gif: return "GIF"
case .tiff: return "TIFF"
case .bmp: return "BMP"
case .webp: return "WebP"
case .heic: return "HEIC"
case .avif: return "AVIF"
case .pdf: return "PDF"
case .text: return "text"
}
}
}

protocol FormatDetector: Sendable {
Expand Down
10 changes: 10 additions & 0 deletions Sources/XPBCCore/FormatValidator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import Foundation

public enum ValidationResult: Equatable, Sendable {
case valid
case invalid(reason: String)
}

protocol FormatValidator: Sendable {
func validate(_ data: Data) -> ValidationResult
}
20 changes: 18 additions & 2 deletions Sources/XPBCCore/PasteboardWriter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,30 @@ public struct PasteboardWriter: Sendable {
}

private func decodeText(from data: Data) -> String {
let decoded: String
if let utf8 = String(data: data, encoding: .utf8) {
return utf8
decoded = utf8
} else {
FileHandle.standardError.write(
Data("xpbc: warning: input is not valid UTF-8, falling back to Latin-1\n".utf8)
)
// Latin-1 can decode any byte sequence, so this never returns nil
return String(data: data, encoding: .isoLatin1)!
decoded = String(data: data, encoding: .isoLatin1)!
}
return stripControlCharacters(decoded)
}

/// Strip C0 control characters (except tab, newline, carriage return) and DEL
/// to prevent terminal escape sequence injection.
/// Checks all Unicode scalars in each Character to handle multi-scalar graphemes.
func stripControlCharacters(_ text: String) -> String {
text.filter { ch in
ch.unicodeScalars.allSatisfy { scalar in
let v = scalar.value
if v == 0x09 || v == 0x0A || v == 0x0D { return true }
if v < 0x20 || v == 0x7F { return false }
return true
}
}
}

Expand Down
18 changes: 18 additions & 0 deletions Sources/XPBCCore/Validators/BMPValidator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import Foundation

struct BMPValidator: FormatValidator {
private static let validDIBSizes: Set<UInt32> = [12, 40, 52, 56, 108, 124]

func validate(_ data: Data) -> ValidationResult {
// BMP file header is 14 bytes, then DIB header starts with its size (LE u32)
guard let dibSize = data.readLittleEndianUInt32(at: 14) else {
return .invalid(reason: "too short for DIB header size (need >= 18 bytes)")
}

guard Self.validDIBSizes.contains(dibSize) else {
return .invalid(reason: "invalid DIB header size \(dibSize)")
}

return .valid
}
}
28 changes: 28 additions & 0 deletions Sources/XPBCCore/Validators/ByteReader.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import Foundation

extension Data {
func readBigEndianUInt32(at offset: Int) -> UInt32? {
guard offset >= 0, offset + 4 <= count else { return nil }
let start = startIndex + offset
return UInt32(self[start]) << 24
| UInt32(self[start + 1]) << 16
| UInt32(self[start + 2]) << 8
| UInt32(self[start + 3])
}

func readLittleEndianUInt32(at offset: Int) -> UInt32? {
guard offset >= 0, offset + 4 <= count else { return nil }
let start = startIndex + offset
return UInt32(self[start])
| UInt32(self[start + 1]) << 8
| UInt32(self[start + 2]) << 16
| UInt32(self[start + 3]) << 24
}

func readLittleEndianUInt16(at offset: Int) -> UInt16? {
guard offset >= 0, offset + 2 <= count else { return nil }
let start = startIndex + offset
return UInt16(self[start])
| UInt16(self[start + 1]) << 8
}
}
24 changes: 24 additions & 0 deletions Sources/XPBCCore/Validators/FtypValidator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import Foundation

struct FtypValidator: FormatValidator {
func validate(_ data: Data) -> ValidationResult {
guard let boxSize = data.readBigEndianUInt32(at: 0) else {
return .invalid(reason: "too short for ftyp box (need >= 4 bytes)")
}

// Per ISO BMFF, boxSize == 0 means "box extends to EOF" and boxSize == 1 means
// "64-bit extended size follows". Both are valid but rejected here for simplicity
// since typical ftyp boxes have a concrete small size.
// Minimum 12: box header (8) + major brand (4). Full ftyp also has minor_version (4)
// but we check for 12 as the bare minimum for a recognizable ftyp box.
guard boxSize >= 12 else {
return .invalid(reason: "ftyp box size \(boxSize) is less than minimum (12)")
}

guard Int(boxSize) <= data.count else {
return .invalid(reason: "ftyp box size \(boxSize) exceeds data size \(data.count)")
}

return .valid
}
}
21 changes: 21 additions & 0 deletions Sources/XPBCCore/Validators/GIFValidator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Foundation

struct GIFValidator: FormatValidator {
func validate(_ data: Data) -> ValidationResult {
guard let width = data.readLittleEndianUInt16(at: 6) else {
return .invalid(reason: "too short for Logical Screen Descriptor (need >= 8 bytes)")
}
guard width > 0 else {
return .invalid(reason: "logical screen width is 0")
}

guard let height = data.readLittleEndianUInt16(at: 8) else {
return .invalid(reason: "too short for Logical Screen Descriptor (need >= 10 bytes)")
}
guard height > 0 else {
return .invalid(reason: "logical screen height is 0")
}

return .valid
}
}
25 changes: 25 additions & 0 deletions Sources/XPBCCore/Validators/JPEGValidator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Foundation

struct JPEGValidator: FormatValidator {
func validate(_ data: Data) -> ValidationResult {
// After SOI (FF D8), next should be FF xx where xx in 0xC0...0xFE
guard data.count >= 4 else {
return .invalid(reason: "too short for marker after SOI (need >= 4 bytes)")
}

guard data[data.startIndex + 2] == 0xFF else {
return .invalid(
reason: "expected 0xFF at offset 2, got 0x\(String(format: "%02X", data[data.startIndex + 2]))"
)
}

let marker = data[data.startIndex + 3]
guard (0xC0...0xFE).contains(marker) else {
return .invalid(
reason: "invalid marker 0xFF\(String(format: "%02X", marker)) at offset 2"
)
}

return .valid
}
}
Loading