Skip to content
Open
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
40 changes: 30 additions & 10 deletions Sources/Container-Compose/Commands/ComposeUp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,25 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
return imageToRun
}

/// Normalize a bind-mount source path to an absolute, `.`/`..`-collapsed
/// host path suitable for passing to `container run -v`.
///
/// - `./foo`, `foo/bar`, `../foo` → `<cwd>/foo`, `<cwd>/foo/bar`, `<parent>/foo`
/// - `/abs/foo` → unchanged
/// - `~/foo` → kept as-is (apple/container handles tilde
/// expansion itself; FileManager would
/// not, so leaving it literal preserves
/// the existing pre-fix behavior)
static func resolveBindMountSource(_ source: String, cwd: String) -> String {
// Already-absolute or tilde-prefixed paths pass through; the daemon
// (or FileManager for the directory-create branch) handles them.
if source.starts(with: "/") || source.starts(with: "~") {
return source
}
let joined = "\(cwd)/\(source)"
return URL(fileURLWithPath: joined).standardizedFileURL.path(percentEncoded: false)
}

private func configVolume(_ volume: String) async throws -> [String] {
let resolvedVolume = resolveVariable(volume, with: environmentVariables)

Expand All @@ -704,30 +723,31 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
// Check if the source looks like a host path (contains '/' or starts with '.')
// This heuristic helps distinguish bind mounts from named volume references.
if source.contains("/") || source.starts(with: ".") || source.starts(with: "..") {
// This is likely a bind mount (local path to container path)
// This is likely a bind mount (local path to container path).
// Apple `container` rejects relative paths in `-v` (its volume-name
// regex is `^[A-Za-z0-9][A-Za-z0-9_.-]*$`, which fails on `./app`).
// Resolve to an absolute, normalized path before passing through.
let normalizedHostPath = Self.resolveBindMountSource(source, cwd: cwd)
var isDirectory: ObjCBool = false
// Ensure the path is absolute or relative to the current directory for FileManager
let fullHostPath = (source.starts(with: "/") || source.starts(with: "~")) ? source : (cwd + "/" + source)

if fileManager.fileExists(atPath: fullHostPath, isDirectory: &isDirectory) {
if fileManager.fileExists(atPath: normalizedHostPath, isDirectory: &isDirectory) {
if isDirectory.boolValue {
// Host path exists and is a directory, add the volume
runCommandArgs.append("-v")
// Reconstruct the volume string without mode, ensuring it's source:destination
runCommandArgs.append("\(source):\(destination)") // Use original source for command argument
runCommandArgs.append("\(normalizedHostPath):\(destination)")
} else {
// Host path exists but is a file
print("Warning: Volume mount source '\(source)' is a file. The 'container' tool does not support direct file mounts. Skipping this volume.")
}
} else {
// Host path does not exist, assume it's meant to be a directory and try to create it.
do {
try fileManager.createDirectory(atPath: fullHostPath, withIntermediateDirectories: true, attributes: nil)
print("Info: Created missing host directory for volume: \(fullHostPath)")
try fileManager.createDirectory(atPath: normalizedHostPath, withIntermediateDirectories: true, attributes: nil)
print("Info: Created missing host directory for volume: \(normalizedHostPath)")
runCommandArgs.append("-v")
runCommandArgs.append("\(source):\(destination)") // Use original source for command argument
runCommandArgs.append("\(normalizedHostPath):\(destination)")
} catch {
print("Error: Could not create host directory '\(fullHostPath)' for volume '\(resolvedVolume)': \(error.localizedDescription). Skipping this volume.")
print("Error: Could not create host directory '\(normalizedHostPath)' for volume '\(resolvedVolume)': \(error.localizedDescription). Skipping this volume.")
}
}
} else {
Expand Down
92 changes: 92 additions & 0 deletions Tests/Container-Compose-StaticTests/BindMountResolutionTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Morris Richman and the Container-Compose project authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//

import Testing
import Foundation
@testable import ContainerComposeCore

/// Coverage for bind-mount source path normalization (issue #4). Apple
/// `container` rejects relative paths in `-v` because its volume-name
/// validation regex `^[A-Za-z0-9][A-Za-z0-9_.-]*$` doesn't match strings
/// starting with `./`, `../`, or containing `/`. Our helper resolves the
/// source against the project's working directory and collapses `.` / `..`
/// segments before the path goes into the run args.
@Suite("Bind Mount Source Resolution")
struct BindMountResolutionTests {

@Test("./foo resolves to <cwd>/foo")
func dotSlashRelative() {
let result = ComposeUp.resolveBindMountSource("./foo", cwd: "/work/proj")
#expect(result == "/work/proj/foo")
}

@Test("bare relative path foo/bar resolves under <cwd>")
func bareRelative() {
let result = ComposeUp.resolveBindMountSource("foo/bar", cwd: "/work/proj")
#expect(result == "/work/proj/foo/bar")
}

@Test("../foo resolves to <parent>/foo")
func parentRelative() {
let result = ComposeUp.resolveBindMountSource("../shared", cwd: "/work/proj")
#expect(result == "/work/shared")
}

@Test("intermediate .. collapses (foo/../bar)")
func intermediateDotDot() {
let result = ComposeUp.resolveBindMountSource("foo/../bar", cwd: "/work/proj")
#expect(result == "/work/proj/bar")
}

@Test("absolute path /abs/foo passes through unchanged")
func absolutePath() {
let result = ComposeUp.resolveBindMountSource("/var/lib/data", cwd: "/work/proj")
#expect(result == "/var/lib/data")
}

@Test("absolute path with intermediate /./ does NOT get rewritten (already absolute)")
func absoluteUnchanged() {
// Behavior choice: an already-absolute path is passed through verbatim.
// The user wrote what they meant; we don't second-guess.
let result = ComposeUp.resolveBindMountSource("/foo/./bar", cwd: "/work/proj")
#expect(result == "/foo/./bar")
}

@Test("~/foo passes through unchanged (left to apple/container to expand)")
func tildePath() {
// FileManager doesn't expand `~` and apple/container does. Preserve
// the literal so the daemon receives the user's exact intent.
let result = ComposeUp.resolveBindMountSource("~/data", cwd: "/work/proj")
#expect(result == "~/data")
}

@Test(".//foo collapses double slash")
func doubleSlashCollapses() {
let result = ComposeUp.resolveBindMountSource(".//foo", cwd: "/work/proj")
#expect(result == "/work/proj/foo")
}

@Test("the issue #4 case: ./app → <cwd>/app (matches the regex container expects)")
func issue4Case() {
let result = ComposeUp.resolveBindMountSource("./app", cwd: "/Users/adrum/Developer/test")
#expect(result == "/Users/adrum/Developer/test/app")
// The post-condition that matters: result starts with `/` and contains
// no `./` or `..` segments — matches what container will accept.
#expect(result.hasPrefix("/"))
#expect(!result.contains("/./"))
#expect(!result.contains("/.."))
}
}
Loading