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
3 changes: 3 additions & 0 deletions .github/workflows/pr_check_macos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ jobs:
working-directory: zstandard_macos
run: flutter analyze

- name: Verify version-resolved SwiftPM and Release FFI symbols
run: python3 scripts/test_swiftpm_package.py

- name: Run package tests
working-directory: zstandard_macos
run: flutter test
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/release_workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,9 @@ jobs:
else
flutter config --no-enable-swift-package-manager
fi
- name: Verify version-resolved SwiftPM and Release FFI symbols
if: matrix.platform == 'macos' && matrix.dependency_manager == 'swiftpm'
run: python3 scripts/test_swiftpm_package.py
- name: Get dependencies and build Apple example
env:
PLATFORM: ${{ matrix.platform }}
Expand Down
26 changes: 8 additions & 18 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ let package = Package(
products: [
.library(
name: "zstandard-native",
targets: ["zstandard_native"]
targets: ["zstandard_ffi"]
),
],
targets: [
Expand All @@ -36,24 +36,14 @@ let package = Package(
// internal zstd translation units consume these declarations.
.define("ZSTD_STATIC_LINKING_ONLY"),
.define("ZSTD_DISABLE_ASM"),
],
// Flutter's SwiftPM integration links this target statically into
// the application. Keep the FFI entry points in the final image
// even when release dead-code stripping is enabled.
linkerSettings: [
.unsafeFlags([
"-Xlinker", "-u", "-Xlinker", "_ZSTD_compress",
"-Xlinker", "-u", "-Xlinker", "_ZSTD_decompress",
"-Xlinker", "-u", "-Xlinker", "_ZSTD_compressBound",
"-Xlinker", "-u", "-Xlinker", "_ZSTD_isError",
"-Xlinker", "-u", "-Xlinker", "_ZSTD_getFrameContentSize",
"-Xlinker", "-u", "-Xlinker", "_ZSTD_createDStream",
"-Xlinker", "-u", "-Xlinker", "_ZSTD_initDStream",
"-Xlinker", "-u", "-Xlinker", "_ZSTD_decompressStream",
"-Xlinker", "-u", "-Xlinker", "_ZSTD_freeDStream",
"-Xlinker", "-u", "-Xlinker", "_ZSTD_DStreamOutSize",
])
]
),
// Keep the Dart-only entry points without unsafe dependency flags.
.target(
name: "zstandard_ffi",
dependencies: ["zstandard_native"],
path: "zstandard_native/src/swiftpm",
publicHeadersPath: "include"
),
]
)
5 changes: 3 additions & 2 deletions docs/architecture/ffi-implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ application. Repository checkouts may use the adjacent package; published
consumers resolve it through Dart's package configuration. Build scripts never
select an arbitrary matching Pub-cache directory.

SwiftPM force-loads the complete FFI symbol surface because its static linker
could otherwise remove Dart-only entry points. CocoaPods builds the equivalent
SwiftPM plugin registration links a C shim with a retained table of the ten
FFI entry points, preventing its static linker from removing Dart-only symbols
without using unsafe package flags. CocoaPods builds the equivalent
C set in an embedded framework. CLI tests open every required symbol; runtime
roundtrips exercise the same codec.

Expand Down
16 changes: 14 additions & 2 deletions docs/development/apple-dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ The iOS and macOS manifests depend on the repository-level SwiftPM facade:
```swift
.package(
url: "https://github.com/vypdev/zstandard.git",
exact: "1.5.0"
exact: "1.5.5"
)
```

Expand All @@ -42,7 +42,12 @@ mutable branches are never used in a published manifest.

The SwiftPM target deliberately excludes unsupported or unnecessary upstream
directories and disables assembly for the Apple build. It also preserves the
FFI entry points when release dead-code stripping is enabled. The public
FFI entry points when release dead-code stripping is enabled. The product
includes a project-owned C shim in `zstandard_native/src/swiftpm/`. SwiftPM
plugin registration calls `ZstandardNativeRetainSymbols`, pulling in a
`used` table of the ten Dart FFI entry points. This keeps version-based
resolution compatible with SwiftPM without `unsafeFlags`; the upstream C
sources and CocoaPods implementation remain unchanged. The public
headers in `zstandard_native/src/zstd/include/` are thin forwarding headers;
the actual headers and all C implementation files remain in the canonical
directory.
Expand All @@ -54,6 +59,13 @@ native integration tests, not only by manifest parsing.

## Verification policy

Run `python3 scripts/test_swiftpm_package.py` on macOS for the standalone
regression. It creates an isolated local Git dependency with a semantic version
tag, builds a consumer in Release with dead stripping, resolves every required
symbol using `dlsym`, and exercises one-shot and streaming roundtrips. A local
path or revision dependency alone would not detect the unsafe-flags regression.


Every Apple change must be tested through both dependency managers on the
ARM64 self-hosted macOS runner:

Expand Down
111 changes: 111 additions & 0 deletions scripts/test_swiftpm_package.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Build a version-resolved SwiftPM consumer and check Release FFI symbols.

The dependency is an isolated, locally tagged copy of the candidate source.
A path or revision dependency would bypass SwiftPM's unsafe-flags check.
"""
from pathlib import Path
import platform
import shutil
import subprocess
import tempfile

ROOT = Path(__file__).resolve().parents[1]

PROBE = r'''#include <dlfcn.h>
#include <stdio.h>
#include <string.h>
#include <zstd.h>
#include <zstandard_ffi.h>

#define REQUIRE(condition) do { \
if (!(condition)) { \
fprintf(stderr, "Failed: %s\n", #condition); \
return 1; \
} \
} while (0)

#define LOAD(name) \
__typeof__(&name) name##Fn = (__typeof__(&name))dlsym(RTLD_DEFAULT, #name); \
REQUIRE(name##Fn != NULL)

int main(void) {
ZstandardNativeRetainSymbols();
LOAD(ZSTD_compress);
LOAD(ZSTD_decompress);
LOAD(ZSTD_compressBound);
LOAD(ZSTD_isError);
LOAD(ZSTD_getFrameContentSize);
LOAD(ZSTD_createDStream);
LOAD(ZSTD_initDStream);
LOAD(ZSTD_decompressStream);
LOAD(ZSTD_freeDStream);
LOAD(ZSTD_DStreamOutSize);

const char input[] = "Version-resolved SwiftPM FFI roundtrip";
char compressed[256];
char output[256];
REQUIRE(ZSTD_compressBoundFn(sizeof(input)) <= sizeof(compressed));
size_t size = ZSTD_compressFn(compressed, sizeof(compressed), input, sizeof(input), 3);
REQUIRE(!ZSTD_isErrorFn(size));
REQUIRE(ZSTD_getFrameContentSizeFn(compressed, size) == sizeof(input));
REQUIRE(ZSTD_decompressFn(output, sizeof(output), compressed, size) == sizeof(input));
REQUIRE(memcmp(input, output, sizeof(input)) == 0);

ZSTD_DStream *stream = ZSTD_createDStreamFn();
REQUIRE(stream != NULL);
REQUIRE(!ZSTD_isErrorFn(ZSTD_initDStreamFn(stream)));
REQUIRE(ZSTD_DStreamOutSizeFn() > 0);
ZSTD_inBuffer source = { compressed, size, 0 };
ZSTD_outBuffer destination = { output, sizeof(output), 0 };
REQUIRE(ZSTD_decompressStreamFn(stream, &destination, &source) == 0);
REQUIRE(source.pos == size && destination.pos == sizeof(input));
REQUIRE(memcmp(input, output, sizeof(input)) == 0);
REQUIRE(!ZSTD_isErrorFn(ZSTD_freeDStreamFn(stream)));
puts("All 10 FFI symbols survive Release dead stripping; one-shot and streaming roundtrips pass.");
return 0;
}
'''


def run(*command, cwd):
subprocess.run(command, cwd=cwd, check=True, timeout=300)


def main():
if platform.system() != 'Darwin':
raise SystemExit('This Apple linker regression test requires macOS and Swift.')
with tempfile.TemporaryDirectory(prefix='zstandard-swiftpm-') as folder:
root = Path(folder)
dependency = root / 'zstandard'
dependency.mkdir()
shutil.copyfile(ROOT / 'Package.swift', dependency / 'Package.swift')
shutil.copytree(ROOT / 'zstandard_native/src', dependency / 'zstandard_native/src')
run('git', 'init', '-q', cwd=dependency)
run('git', 'add', '.', cwd=dependency)
run('git', '-c', 'user.name=SwiftPM regression', '-c',
'user.email=swiftpm-test@example.invalid', 'commit', '-qm', 'Candidate fixture', cwd=dependency)
run('git', 'tag', '1.0.0', cwd=dependency)

consumer = root / 'consumer'
(consumer / 'Sources/Probe').mkdir(parents=True)
(consumer / 'Sources/Probe/main.c').write_text(PROBE)
(consumer / 'Package.swift').write_text('''// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "Probe",
platforms: [.macOS(.v10_15)],
dependencies: [.package(url: "''' + dependency.as_uri() + '''", exact: "1.0.0")],
targets: [.executableTarget(name: "Probe", dependencies: [
.product(name: "zstandard-native", package: "zstandard")
])]
)
''')
build = root / 'build'
run('swift', 'build', '--configuration', 'release', '--scratch-path', str(build),
'--cache-path', str(root / 'cache'), '-Xlinker', '-dead_strip', cwd=consumer)
run(str(build / 'release/Probe'), cwd=consumer)


if __name__ == '__main__':
main()
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import Flutter
import UIKit
#if SWIFT_PACKAGE
import zstandard_ffi
#endif

public class ZstandardIosPlugin: NSObject, FlutterPlugin {
public static func register(with registrar: FlutterPluginRegistrar) {
#if SWIFT_PACKAGE
ZstandardNativeRetainSymbols()
#endif
let channel = FlutterMethodChannel(name: "plugins.flutter.io/zstandard", binaryMessenger: registrar.messenger())
let instance = ZstandardIosPlugin()
registrar.addMethodCallDelegate(instance, channel: channel)
Expand Down
14 changes: 2 additions & 12 deletions zstandard_ios/test/spm_package_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,8 @@ void main() {
if (rootManifest.existsSync()) {
final rootContents = rootManifest.readAsStringSync();
expect(rootContents, contains('path: "zstandard_native/src/zstd"'));
for (final symbol in [
'_ZSTD_compress',
'_ZSTD_decompress',
'_ZSTD_isError',
'_ZSTD_createDStream',
'_ZSTD_initDStream',
'_ZSTD_decompressStream',
'_ZSTD_freeDStream',
'_ZSTD_DStreamOutSize',
]) {
expect(rootContents, contains(symbol));
}
expect(rootContents, isNot(contains('unsafeFlags')));
expect(rootContents, contains('targets: ["zstandard_ffi"]'));
}
});
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import Cocoa
import FlutterMacOS
#if SWIFT_PACKAGE
import zstandard_ffi
#endif

public class ZstandardMacosPlugin: NSObject, FlutterPlugin {
public static func register(with registrar: FlutterPluginRegistrar) {
#if SWIFT_PACKAGE
ZstandardNativeRetainSymbols()
#endif
let channel = FlutterMethodChannel(name: "plugins.flutter.io/zstandard", binaryMessenger: registrar.messenger)
let instance = ZstandardMacosPlugin()
registrar.addMethodCallDelegate(instance, channel: channel)
Expand Down
14 changes: 2 additions & 12 deletions zstandard_macos/test/spm_package_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,8 @@ void main() {
if (rootManifest.existsSync()) {
final rootContents = rootManifest.readAsStringSync();
expect(rootContents, contains('path: "zstandard_native/src/zstd"'));
for (final symbol in [
'_ZSTD_compress',
'_ZSTD_decompress',
'_ZSTD_isError',
'_ZSTD_createDStream',
'_ZSTD_initDStream',
'_ZSTD_decompressStream',
'_ZSTD_freeDStream',
'_ZSTD_DStreamOutSize',
]) {
expect(rootContents, contains(symbol));
}
expect(rootContents, isNot(contains('unsafeFlags')));
expect(rootContents, contains('targets: ["zstandard_ffi"]'));
}
});
}
15 changes: 15 additions & 0 deletions zstandard_native/src/swiftpm/include/zstandard_ffi.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#ifndef ZSTANDARD_FFI_H
#define ZSTANDARD_FFI_H

#ifdef __cplusplus
extern "C" {
#endif

// Call during SwiftPM plugin registration to link the Dart FFI entry points.
void ZstandardNativeRetainSymbols(void);

#ifdef __cplusplus
}
#endif

#endif
23 changes: 23 additions & 0 deletions zstandard_native/src/swiftpm/zstandard_ffi.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#include "zstandard_ffi.h"
#include <zstd.h>

// Mach-O's used attribute protects this table and its references from Release
// dead stripping. Registration pulls this object into the statically linked app.
// Keep this project-owned shim outside the vendored upstream zstd source tree.
__attribute__((used))
static void (*const zstandard_ffi_symbols[])(void) = {
(void (*)(void))ZSTD_compress,
(void (*)(void))ZSTD_decompress,
(void (*)(void))ZSTD_compressBound,
(void (*)(void))ZSTD_isError,
(void (*)(void))ZSTD_getFrameContentSize,
(void (*)(void))ZSTD_createDStream,
(void (*)(void))ZSTD_initDStream,
(void (*)(void))ZSTD_decompressStream,
(void (*)(void))ZSTD_freeDStream,
(void (*)(void))ZSTD_DStreamOutSize,
};

void ZstandardNativeRetainSymbols(void) {
// Linking this object retains the used symbol table; no zstd call is needed.
}
Loading