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: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ jobs:
with:
persist-credentials: false

- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: crocmobile/go.mod
cache-dependency-path: crocmobile/go.sum
Expand Down Expand Up @@ -214,7 +214,7 @@ jobs:
with:
persist-credentials: false

- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: crocmobile/go.mod
cache-dependency-path: crocmobile/go.sum
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ jobs:

echo "version=$version" >> "$GITHUB_OUTPUT"

- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: crocmobile/go.mod
cache-dependency-path: crocmobile/go.sum
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/reusable-go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
with:
persist-credentials: false

- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: crocmobile/go.mod
cache-dependency-path: crocmobile/go.sum
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

- A receive no longer overwrites existing files by default. A zipped folder received into a folder that already holds it no longer fails. Answers left over from one transfer can no longer answer the next transfer's accept prompt.

### Security

- Sending a file whose name contains `*` sends only that file. Before, croc read the name as a pattern and also sent every matching file next to it.
- A file name containing a line break can no longer turn into extra send paths (a crafted name could send the app's whole data folder), and a name ending in a space is no longer sent as the file without the space (croc 11 receivers refuse such a name, so that send now fails). Received or shared names with control characters are treated as unsafe.
- After you accept a receive, a sender that reconnects cannot get a different file list accepted without you seeing it: the receive ends instead. Replace still overwrites files, but no longer overwrites existing files inside a zipped folder; they are kept.
- A sender declaring impossible file sizes can no longer crash the app during a receive.

## [0.9.9] - 2026-07-30

First public build of CrocApp. It is a symbolic release: the app does everything V1 is meant to do, but it is not signed with an Apple Developer ID and it is not notarized, so macOS refuses to open it until you allow it by hand. Read "Opening it the first time" below before downloading.
Expand Down
14 changes: 12 additions & 2 deletions CrocKit/Sources/CrocKit/CrocEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public actor CrocEngine {
paths: [String], text: String?, options: EngineOptions
) throws -> EngineTransfer {
let opts = try Self.crocOptions(from: options)
let joined = paths.joined(separator: "\n")
let joined = try Self.joinedPaths(paths)
return try start { bridge, error in
CrocmobileStartSend(joined, text ?? "", opts, bridge, error)
}
Expand All @@ -55,7 +55,7 @@ public actor CrocEngine {
opts.lifetimeSeconds = options.lifetimeSeconds
opts.socks5Proxy = options.proxy.socks5
opts.httpProxy = options.proxy.http
let joined = paths.joined(separator: "\n")
let joined = try Self.joinedPaths(paths)
return try start { bridge, error in
CrocmobileStartStoreSend(joined, opts, bridge, error)
}
Expand Down Expand Up @@ -145,6 +145,16 @@ public actor CrocEngine {
bridge = nil
}

/// The Go side splits the list on "\n", so a path holding a line break would
/// turn into several send paths (`a.jpg\n..` sends the parent folder).
private static func joinedPaths(_ paths: [String]) throws -> String {
if paths.contains(where: { $0.contains(where: \.isNewline) }) {
throw CrocEngineError.startFailed(
EngineFailure(code: .invalidSetting, message: "a file name contains a line break"))
}
return paths.joined(separator: "\n")
}

private static func storeOptions(proxy: ProxySettings) -> CrocmobileStoreOptions {
let opts = CrocmobileStoreOptions()
opts.socks5Proxy = proxy.socks5
Expand Down
37 changes: 37 additions & 0 deletions CrocKit/Tests/CrocKitTests/SendPathValidationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Foundation
import Testing

@testable import CrocKit

/// The Go side splits the joined path list on "\n", so a path carrying a line
/// break would become several send paths ("a.jpg\n.." sends the parent folder).
@Suite struct SendPathValidationTests {
private func isRefused(_ start: () async throws -> EngineTransfer) async -> Bool {
do {
_ = try await start()
return false
} catch CrocEngineError.startFailed(let failure) {
return failure.code == .invalidSetting
} catch {
return false
}
}

@Test(arguments: ["photo.jpg\n..", "a\rb", "/tmp/a\r\nb", "\n"])
func sendRefusesLineBreakInPath(path: String) async {
let engine = CrocEngine()
let refused = await isRefused {
try await engine.startSend(paths: ["/tmp/ok.txt", path], text: nil, options: .init())
}
#expect(refused)
}

@Test(arguments: ["photo.jpg\n..", "a\rb", "/tmp/a\r\nb", "\n"])
func storeSendRefusesLineBreakInPath(path: String) async {
let engine = CrocEngine()
let refused = await isRefused {
try await engine.startStoreSend(paths: [path], options: StoredSendOptions())
}
#expect(refused)
}
}
5 changes: 4 additions & 1 deletion app/CrocApp/Support/ReceivedName.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ import Foundation

enum ReceivedName {
/// True when a name from an incoming file list must block the transfer:
/// absolute path, parent-directory traversal, backslash, or NUL byte.
/// absolute path, parent-directory traversal, backslash, or a control character (NUL, line breaks, tab, DEL).
/// Plain relative subpaths ("folder/file.txt") are legitimate for
/// folder transfers and stay allowed.
static func isUnsafe(_ name: String) -> Bool {
if name.isEmpty || name.hasPrefix("/") || name.contains("\\") || name.contains("\0") {
return true
}
if name.unicodeScalars.contains(where: { $0.properties.generalCategory == .control }) {
return true
}
return name.split(separator: "/", omittingEmptySubsequences: false).contains("..")
}
}
17 changes: 17 additions & 0 deletions app/CrocApp/Views/AdvancedSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ struct AdvancedSettingsView: View {
@Environment(AppSettings.self) private var settings
@State private var confirmReset = false

private enum NumberField: Hashable {
case basePort, transfers
}
@FocusState private var focusedNumberField: NumberField?

var body: some View {
@Bindable var settings = settings

Expand Down Expand Up @@ -126,6 +131,7 @@ struct AdvancedSettingsView: View {
format: .number.grouping(.never)
)
.numberEntry()
.focused($focusedNumberField, equals: .basePort)
}
}
AdvancedRow(
Expand All @@ -139,6 +145,7 @@ struct AdvancedSettingsView: View {
format: .number.grouping(.never)
)
.numberEntry()
.focused($focusedNumberField, equals: .transfers)
}
}
AdvancedRow(
Expand All @@ -162,6 +169,15 @@ struct AdvancedSettingsView: View {
.navigationTitle("Advanced")
#if os(iOS)
.scrollDismissesKeyboard(.interactively)
// The number pad has no Return key to dismiss it.
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
if focusedNumberField != nil {
Spacer()
Button("Done") { focusedNumberField = nil }
}
}
}
#endif
.confirmationDialog(
"Reset all Advanced settings?", isPresented: $confirmReset, titleVisibility: .visible
Expand Down Expand Up @@ -202,6 +218,7 @@ private struct AdvancedRow<Content: View>: View {
var body: some View {
VStack(alignment: .leading, spacing: Spacing.space3) {
content
.accessibilityHint(error ?? footnote)
if let error {
StatusBanner(kind: .error, title: error)
.frame(maxWidth: .infinity, alignment: .leading)
Expand Down
17 changes: 16 additions & 1 deletion app/CrocApp/Views/CodeField.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ struct CodeField: View {
let placeholder: String
@Binding var text: String
var isInvalid: Bool = false
/// Says in words what is wrong, shown under the field and read by
/// VoiceOver with it; the red border alone is not enough.
var invalidMessage: String?
var isDisabled: Bool = false
/// Receives the pasted string on the main actor; the call site decides
/// what counts as a valid paste (e.g. ReceiveView's deeplink parsing).
Expand All @@ -31,12 +34,24 @@ struct CodeField: View {
private static let codeTracking: CGFloat = 0.5 // --tracking-code, typography.md

var body: some View {
VStack(alignment: .leading, spacing: Spacing.space2) {
field
if isInvalid, let invalidMessage {
Text(invalidMessage)
.font(.caption)
.foregroundStyle(Color.textSecondary)
}
}
}

private var field: some View {
HStack(spacing: Spacing.space2) {
TextField(placeholder, text: $text)
.textFieldStyle(.plain)
.font(.body.monospaced()) // --type-code-body, swiftui-mapping.md → Typography
.tracking(Self.codeTracking)
.focused($isFocused)
.accessibilityHint(isInvalid ? (invalidMessage ?? "") : "")
.autocorrectionDisabled()
#if os(iOS)
.textInputAutocapitalization(.never)
Expand All @@ -54,7 +69,7 @@ struct CodeField: View {
}
.padding(.leading, Self.leadingPadding)
.padding(.trailing, Self.trailingPadding)
.frame(height: ControlHeight.large)
.frame(minHeight: ControlHeight.large)
.background(
Color.surfaceBase, in: RoundedRectangle(cornerRadius: Radius.md, style: .continuous)
)
Expand Down
44 changes: 35 additions & 9 deletions app/CrocApp/Views/FileRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,30 +18,46 @@ struct FileRow: View {
/// line 114: "the last 8 characters ... are pinned").
private static let pinnedTailLength = 8

@Environment(\.dynamicTypeSize) private var dynamicTypeSize

var body: some View {
HStack(spacing: Spacing.space3) {
Image(systemName: typeSymbolName)
.font(.system(size: Self.glyphSize))
.foregroundStyle(Color.accentFill)
.accessibilityHidden(true)

filenameLabel
.font(.callout) // typography.md: --type-callout is used for "file names"
.foregroundStyle(Color.textPrimary)
if dynamicTypeSize.isAccessibilitySize {
// The pinned-tail label keeps 8 characters unshrinkable, which
// is wider than the row at these sizes: wrap the name and put
// the size under it.
VStack(alignment: .leading, spacing: Spacing.space2) {
Text(url.lastPathComponent)
.font(.callout)
.foregroundStyle(Color.textPrimary)
.lineLimit(3)
.truncationMode(.middle)
sizeLabel
}
.frame(maxWidth: .infinity, alignment: .leading)
} else {
filenameLabel
.font(.callout) // typography.md: --type-callout is used for "file names"
.foregroundStyle(Color.textPrimary)

Spacer(minLength: Spacing.space3)
Spacer(minLength: Spacing.space3)

if let sizeText {
Text(sizeText)
.font(.footnote)
.foregroundStyle(Color.textSecondary)
.monospacedDigit()
sizeLabel
}

Button(action: onRemove) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: Self.glyphSize))
.foregroundStyle(Color.textSecondary)
#if os(iOS)
.frame(minWidth: ControlHeight.hitTargetMin, minHeight: ControlHeight.hitTargetMin)
.contentShape(Rectangle())
#endif
}
.buttonStyle(.plain)
.accessibilityLabel("Remove \(url.lastPathComponent)")
Expand All @@ -62,6 +78,16 @@ struct FileRow: View {
.clipShape(rowShape)
}

@ViewBuilder private var sizeLabel: some View {
if let sizeText {
Text(sizeText)
.font(.footnote)
.foregroundStyle(Color.textSecondary)
.monospacedDigit()
.lineLimit(1)
}
}

private var rowShape: some Shape {
UnevenRoundedRectangle(
topLeadingRadius: isFirst ? Radius.md : 0,
Expand Down
Loading
Loading