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
2 changes: 1 addition & 1 deletion Sources/PostServer/PostServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -823,7 +823,7 @@ public actor PostServer {
}

return AttachmentData(
filename: part.filename ?? part.suggestedFilename,
filename: part.filename.flatMap { $0.isEmpty ? nil : $0 } ?? part.suggestedFilename,
contentType: part.contentType,
data: data.base64EncodedString(),
size: data.count
Expand Down
5 changes: 3 additions & 2 deletions Sources/post/Commands/Attachment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ extension PostCLI {
@Option(name: .long, help: "Mailbox name")
var mailbox: String = "INBOX"

@Option(name: .long, help: "Output path — an existing directory, or a path ending in /, takes the attachment's own filename; anything else is the filename to write")
@Option(name: .long, help: ArgumentHelp("Output path — an existing directory, or a path ending in /, takes the attachment's own filename "
+ "(folders in it, as Outlook writes them, become subfolders); anything else is the filename to write"))
var output: String = "."

func validate() throws {
Expand All @@ -40,7 +41,7 @@ extension PostCLI {
return
}

let destination = try OutputPath.destination(for: output, named: attachment.filename)
let destination = try OutputPath.destination(for: output, attachmentNamed: attachment.filename)
let displayName = destination.lastPathComponent
try data.write(to: destination)
print("Saved \(displayName) (\(attachment.contentType), \(attachment.size.formattedAsBytes())) to \(destination.path)")
Expand Down
9 changes: 7 additions & 2 deletions Sources/post/Commands/LocalMessageFile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,8 @@ enum LocalMessageFile {
@Option(name: .long, help: "Section of a single part to write out, as `--list-parts` prints it (e.g. 4.2)")
var part: String?

@Option(name: .long, help: "Output path for --part — an existing directory, or a path ending in /, takes a file named after the part; anything else is the filename to write")
@Option(name: .long, help: ArgumentHelp("Output path for --part — an existing directory, or a path ending in /, takes a file named after the part "
+ "(folders in its name become subfolders); anything else is the filename to write"))
var output: String = "."

func validate() throws {
Expand Down Expand Up @@ -226,7 +227,11 @@ enum LocalMessageFile {
throw ValidationError("Part \(section) (\(part.contentType)) carries no bytes of its own.\(hint)")
}

let destination = try OutputPath.destination(for: output, named: part.suggestedFilename)
// The part's own name, not `suggestedFilename`, which strips `\` and
// `/` and would run a path's folders together into one name. An empty
// name counts as none, so `suggestedFilename` names it (`part_2.pdf`).
let name = part.filename.flatMap { $0.isEmpty ? nil : $0 } ?? part.suggestedFilename
let destination = try OutputPath.destination(for: output, attachmentNamed: name)
try data.write(to: destination)
print("Saved \(destination.lastPathComponent) (\(part.contentType), \(data.count.formattedAsBytes())) "
+ "to \(destination.path)")
Expand Down
40 changes: 40 additions & 0 deletions Sources/post/OutputPath.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import SwiftMail

/// How every `--output` option decides whether it was given a directory or a
/// filename.
Expand Down Expand Up @@ -35,4 +36,43 @@ enum OutputPath {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
return wantsDirectory ? directory.appendingPathComponent(filename) : url
}

/// The file to write for an attachment whose name a sender chose.
///
/// Like ``destination(for:named:)``, except that when `output` names a
/// directory the attachment's name may be a path: Outlook names files it
/// attaches from a folder `docs\reference\a.md`, and that lands as
/// `docs/reference/a.md` under the directory instead of one file with
/// backslashes in its name. Only ``relativePath(forAttachmentNamed:)``
/// decides what of the name is kept, so it cannot leave the directory.
static func destination(for output: String, attachmentNamed name: String) throws -> URL {
guard namesDirectory(output) else {
return try destination(for: output, named: name)
}
let file = relativePath(forAttachmentNamed: name).reduce(URL(fileURLWithPath: output)) {
$0.appendingPathComponent($1)
}
try FileManager.default.createDirectory(
at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
return file
}

/// The folders and filename an attachment's name stands for, each safe to
/// use as one path component.
///
/// `/` and `\` both separate folders. Empty components, `.` and `..` are
/// dropped, and so is a leading drive letter (`C:`), so the path stays
/// relative and inside the directory it is written to. Each component is
/// stripped of the characters file systems refuse. A name that leaves
/// nothing is `attachment`.
static func relativePath(forAttachmentNamed name: String) -> [String] {
var components = name.split(whereSeparator: { $0 == "/" || $0 == "\\" }).map(String.init)
if let first = components.first, first.count == 2, first.last == ":", first.first?.isLetter == true {
components.removeFirst()
}
let kept = components
.map { $0.sanitizedFileName().trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty && $0 != "." && $0 != ".." }
return kept.isEmpty ? ["attachment"] : kept
}
}
16 changes: 14 additions & 2 deletions Tests/PostCLITests/LocalPartExtractionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,10 @@ final class LocalPartExtractionTests: XCTestCase {
--outer--
"""

private func writeSampleEML() throws -> String {
private func writeSampleEML(_ eml: String = sampleEML) throws -> String {
let url = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("post-parts-\(UUID().uuidString).eml")
try Data(Self.sampleEML.utf8).write(to: url)
try Data(eml.utf8).write(to: url)
addTeardownBlock { try? FileManager.default.removeItem(at: url) }
return url.path
}
Expand Down Expand Up @@ -112,6 +112,18 @@ final class LocalPartExtractionTests: XCTestCase {
XCTAssertEqual(try Data(contentsOf: destination), Data("%PDF-1.7 fake".utf8))
}

func testExtractNamesAnEmptyFilenameAfterThePart() throws {
// `filename=""` is no name; the part is named after its section and type.
let eml = Self.sampleEML.replacingOccurrences(of: "\"vertrag.pdf\"", with: "\"\"")
let message = try LocalMessageFile.read(try writeSampleEML(eml), as: .eml)
let directory = try makeOutputDirectory()

try LocalMessageFile.extract(message, section: "2", to: directory.path)

let written = directory.appendingPathComponent("part_2.pdf")
XCTAssertEqual(try Data(contentsOf: written), Data("%PDF-1.7 fake".utf8))
}

func testExtractNamesTheAvailableSectionsWhenOneIsWrong() throws {
let message = try LocalMessageFile.read(try writeSampleEML(), as: .eml)
let directory = try makeOutputDirectory()
Expand Down
36 changes: 36 additions & 0 deletions Tests/PostCLITests/OutputPathTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,40 @@ final class OutputPathTests: XCTestCase {
XCTAssertTrue(OutputPath.namesDirectory(directory.path + "/"))
XCTAssertFalse(FileManager.default.fileExists(atPath: directory.path))
}

// MARK: - Attachment names with folders

func testOutlookPathBecomesSubfolders() throws {
// Outlook names a file attached from a folder after its Windows path.
let directory = try makeTemporaryDirectory()

let destination = try OutputPath.destination(
for: directory.path, attachmentNamed: #"zpo-berufung\reference\akt.md"#)

XCTAssertEqual(destination, directory.appendingPathComponent("zpo-berufung/reference/akt.md"))
XCTAssertTrue(isDirectory(directory.appendingPathComponent("zpo-berufung/reference")))
XCTAssertFalse(FileManager.default.fileExists(atPath: destination.path))
}

func testAttachmentPathStaysInsideTheDirectory() {
XCTAssertEqual(OutputPath.relativePath(forAttachmentNamed: "../../.zshrc"), [".zshrc"])
XCTAssertEqual(OutputPath.relativePath(forAttachmentNamed: "/etc/passwd"), ["etc", "passwd"])
XCTAssertEqual(OutputPath.relativePath(forAttachmentNamed: #"C:\Users\x\a.pdf"#), ["Users", "x", "a.pdf"])
XCTAssertEqual(OutputPath.relativePath(forAttachmentNamed: #"\\server\share\.\b.pdf"#),
["server", "share", "b.pdf"])
XCTAssertEqual(OutputPath.relativePath(forAttachmentNamed: #"..\.."#), ["attachment"])
}

func testPlainAttachmentNameIsUnchanged() throws {
let directory = try makeTemporaryDirectory()

XCTAssertEqual(try OutputPath.destination(for: directory.path, attachmentNamed: "vertrag.pdf"),
directory.appendingPathComponent("vertrag.pdf"))
}

func testAttachmentNameIsIgnoredWhenOutputIsAFilename() throws {
let destination = try makeTemporaryDirectory().appendingPathComponent("renamed.md")

XCTAssertEqual(try OutputPath.destination(for: destination.path, attachmentNamed: #"a\b\c.md"#), destination)
}
}
Loading