Skip to content

Commit 9c6bc8a

Browse files
committed
feat: Refactor STExtendedAttributes and improve permissions API
1 parent f48f578 commit 9c6bc8a

File tree

6 files changed

+246
-2
lines changed

6 files changed

+246
-2
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
2+
//
3+
// STPath+Link.swift
4+
//
5+
//
6+
// Created by linhey on 2025/8/1.
7+
//
8+
9+
import Foundation
10+
11+
public extension STPathProtocol {
12+
13+
/// [en] A boolean value indicating whether the path is a symbolic link.
14+
/// [zh] 一个布尔值,指示路径是否为符号链接。
15+
var isSymbolicLink: Bool {
16+
(try? FileManager.default.destinationOfSymbolicLink(atPath: url.path)) != nil
17+
}
18+
19+
/// [en] Creates a symbolic link at the specified path that points to the destination.
20+
/// [zh] 在指定路径创建一个指向目标的符号链接。
21+
/// - Parameters:
22+
/// - path: The path at which to create the symbolic link.
23+
/// - destination: The destination path that the link will point to.
24+
/// - Throws: An error if the link cannot be created.
25+
func createSymbolicLink(to destination: any STPathProtocol) throws {
26+
try FileManager.default.createSymbolicLink(at: url, withDestinationURL: destination.url)
27+
}
28+
29+
/// [en] Returns the destination of the symbolic link.
30+
/// [zh] 返回符号链接的目标。
31+
/// - Returns: The destination path of the symbolic link.
32+
/// - Throws: An error if the destination cannot be resolved.
33+
func destinationOfSymbolicLink() throws -> STPath {
34+
let destinationPath = try FileManager.default.destinationOfSymbolicLink(atPath: url.path)
35+
return STPath(destinationPath)
36+
}
37+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
//
2+
// STPath+Metadata.swift
3+
//
4+
//
5+
// Created by linhey on 2025/8/1.
6+
//
7+
8+
import Foundation
9+
10+
public extension STPathProtocol {
11+
12+
/// [en] Sets the permissions for the file or folder.
13+
/// [zh] 设置文件或文件夹的权限。
14+
/// - Parameter permissions: The permissions to set.
15+
/// - Throws: An error if the permissions cannot be set.
16+
func set(permissions: STPathPermission.Posix) throws {
17+
try FileManager.default.setAttributes([.posixPermissions: permissions.rawValue], ofItemAtPath: url.path)
18+
}
19+
20+
/// [en] Returns the permissions for the file or folder.
21+
/// [zh] 返回文件或文件夹的权限。
22+
/// - Returns: The permissions.
23+
/// - Throws: An error if the permissions cannot be retrieved.
24+
func permissions() throws -> STPathPermission.Posix {
25+
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
26+
if let permissions = attributes[.posixPermissions] as? UInt16 {
27+
return STPathPermission.Posix(rawValue: permissions)
28+
}
29+
throw STPathError(message: "[en] Failed to get permissions. \n [zh] 获取权限失败。", code: 0)
30+
}
31+
32+
33+
}
34+
35+
extension STPathProtocol {
36+
37+
/// [en] Returns the extended attributes of the file or folder.
38+
/// [zh] 返回文件或文件夹的扩展属性。
39+
/// - Returns: An instance of `ExtendedAttributes`.
40+
var extendedAttributes: STExtendedAttributes {
41+
STExtendedAttributes(url: url)
42+
}
43+
44+
}
45+
46+
47+
struct STExtendedAttributes {
48+
49+
let url: URL
50+
51+
/// [en] Sets an extended attribute for the file or folder.
52+
/// [zh] 为文件或文件夹设置扩展属性。
53+
/// - Parameters:
54+
/// - value: The value of the attribute.
55+
/// - forName: The name of the attribute.
56+
/// - Throws: An error if the attribute cannot be set.
57+
func set(name: String, value: Data) throws {
58+
try url.path.withCString { fileSystemPath in
59+
let status = setxattr(fileSystemPath, name, value.withUnsafeBytes { $0.baseAddress! }, value.count, 0, 0)
60+
if status == -1 {
61+
throw STPathError(message: "[en] Failed to set extended attribute \(name). \n [zh] 设置扩展属性 \(name) 失败。", code: Int(errno))
62+
}
63+
}
64+
}
65+
66+
/// [en] Returns the value of an extended attribute.
67+
/// [zh] 返回扩展属性的值。
68+
/// - Parameter forName: The name of the attribute.
69+
/// - Returns: The value of the attribute.
70+
/// - Throws: An error if the attribute cannot be retrieved.
71+
func value(of name: String) throws -> Data {
72+
try url.path.withCString { fileSystemPath in
73+
let length = getxattr(fileSystemPath, name, nil, 0, 0, 0)
74+
if length == -1 {
75+
throw STPathError(message: "[en] Failed to get extended attribute \(name). \n [zh] 获取扩展属性 \(name) 失败。", code: Int(errno))
76+
}
77+
var data = Data(count: length)
78+
let result = data.withUnsafeMutableBytes { buffer in
79+
getxattr(fileSystemPath, name, buffer.baseAddress, length, 0, 0)
80+
}
81+
if result == -1 {
82+
throw STPathError(message: "[en] Failed to get extended attribute \(name). \n [zh] 获取扩展属性 \(name) 失败。", code: Int(errno))
83+
}
84+
return data
85+
}
86+
}
87+
88+
/// [en] Removes an extended attribute from the file or folder.
89+
/// [zh] 从文件或文件夹中删除扩展属性。
90+
/// - Parameter forName: The name of the attribute to remove.
91+
/// - Throws: An error if the attribute cannot be removed.
92+
func remove(of name: String) throws {
93+
try url.path.withCString { fileSystemPath in
94+
let status = removexattr(fileSystemPath, name, 0)
95+
if status == -1 {
96+
throw STPathError(message: "[en] Failed to remove extended attribute \(name). \n [zh] 删除扩展属性 \(name) 失败。", code: Int(errno))
97+
}
98+
}
99+
}
100+
101+
/// [en] Returns a list of all extended attributes.
102+
/// [zh] 返回所有扩展属性的列表。
103+
/// - Returns: A list of attribute names.
104+
/// - Throws: An error if the attributes cannot be retrieved.
105+
func list() throws -> [String] {
106+
try url.path.withCString { fileSystemPath in
107+
let length = listxattr(fileSystemPath, nil, 0, 0)
108+
if length == -1 {
109+
throw STPathError(message: "[en] Failed to list extended attributes. \n [zh] 列出扩展属性失败。", code: Int(errno))
110+
}
111+
var buffer = [CChar](repeating: 0, count: length)
112+
let result = listxattr(fileSystemPath, &buffer, length, 0)
113+
if result == -1 {
114+
throw STPathError(message: "[en] Failed to list extended attributes. \n [zh] 列出扩展属性失败。", code: Int(errno))
115+
}
116+
return buffer.split(separator: 0).compactMap { String(cString: Array($0), encoding: .utf8) }
117+
}
118+
}
119+
}

Sources/STFilePath/STPathPermission.swift

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
12
// MIT License
23
//
34
// Copyright (c) 2020 linhey
@@ -97,3 +98,46 @@ public struct STPathPermission: OptionSet, Comparable, Sendable {
9798
}
9899

99100
}
101+
102+
103+
public extension STPathPermission {
104+
105+
/// [en] A struct that represents the permissions of a path on a POSIX system.
106+
/// [zh] 一个表示 POSIX 系统上路径权限的结构体。
107+
struct Posix: OptionSet, Comparable, Sendable {
108+
109+
public static func < (lhs: STPathPermission.Posix, rhs: STPathPermission.Posix) -> Bool {
110+
lhs.rawValue < rhs.rawValue
111+
}
112+
113+
public let rawValue: UInt16
114+
115+
public init(rawValue: UInt16) {
116+
self.rawValue = rawValue
117+
}
118+
119+
public static let ownerRead = Posix(rawValue: 0o400)
120+
public static let ownerWrite = Posix(rawValue: 0o200)
121+
public static let ownerExecute = Posix(rawValue: 0o100)
122+
public static let ownerAll: Posix = [.ownerRead, .ownerWrite, .ownerExecute]
123+
124+
public static let groupRead = Posix(rawValue: 0o040)
125+
public static let groupWrite = Posix(rawValue: 0o020)
126+
public static let groupExecute = Posix(rawValue: 0o010)
127+
public static let groupAll: Posix = [.groupRead, .groupWrite, .groupExecute]
128+
129+
public static let othersRead = Posix(rawValue: 0o004)
130+
public static let othersWrite = Posix(rawValue: 0o002)
131+
public static let othersExecute = Posix(rawValue: 0o001)
132+
public static let othersAll: Posix = [.othersRead, .othersWrite, .othersExecute]
133+
134+
public static let all: Posix = [.ownerAll, .groupAll, .othersAll]
135+
public static let `default`: Posix = [.ownerAll, .groupRead, .groupExecute, .othersRead, .othersExecute]
136+
137+
public init(fileURL: URL) throws {
138+
let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path)
139+
self.init(rawValue: attributes[.posixPermissions] as? UInt16 ?? 0)
140+
}
141+
}
142+
143+
}

Tests/STFilePathTests/STFolderWatcherTests.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ struct STFolderWatcherTests {
88
@available(iOS 16.0, *)
99
@Test("Folder Watcher Operations")
1010
func testWatcher() async throws {
11+
try await Task.sleep(for: .seconds(2))
12+
1113
let testFolder = try createTestFolder()
1214
defer { try? testFolder.delete() }
1315

@@ -25,14 +27,14 @@ struct STFolderWatcherTests {
2527
#expect(change.file.url.path.replacingOccurrences(of: "/private/var", with: "/var") == file1.url.path.replacingOccurrences(of: "/private/var", with: "/var"))
2628

2729
// 2. Test file modification
28-
try await Task.sleep(for: .milliseconds(200))
30+
try await Task.sleep(for: .seconds(1))
2931
try file1.overlay(with: "world".data(using: .utf8))
3032
change = try await iterator.next()!
3133
#expect(change.kind == .changed)
3234
#expect(change.file.url.path.replacingOccurrences(of: "/private/var", with: "/var") == file1.url.path.replacingOccurrences(of: "/private/var", with: "/var"))
3335

3436
// 3. Test file deletion
35-
try await Task.sleep(for: .milliseconds(200))
37+
try await Task.sleep(for: .seconds(1))
3638
try file1.delete()
3739
change = try await iterator.next()!
3840
#expect(change.kind == .deleted)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
2+
import Testing
3+
import STFilePath
4+
import Foundation
5+
6+
@Suite("STPath.Link Tests")
7+
struct STPathLinkTests {
8+
9+
@Test("Symbolic Link Operations")
10+
func testSymbolicLinkOperations() throws {
11+
let temporaryFolder = try createTestFolder()
12+
let folder = temporaryFolder
13+
14+
let file = folder.file("file.txt")
15+
try file.create(with: "hello".data(using: .utf8))
16+
17+
let link = folder.subpath("link")
18+
try link.createSymbolicLink(to: file)
19+
20+
#expect(link.isSymbolicLink)
21+
#expect(try link.destinationOfSymbolicLink().url == file.url)
22+
23+
let linkedFile = try link.destinationOfSymbolicLink().asFile!
24+
#expect(try linkedFile.read() == "hello")
25+
}
26+
27+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import XCTest
2+
@testable import STFilePath
3+
4+
class STPath_MetadataTests: XCTestCase {
5+
6+
func testPermissions() throws {
7+
let folder = try createTestFolder()
8+
let file = try folder.create(file: "permission_test.txt")
9+
try file.set(permissions: STPathPermission.Posix([.ownerRead, .ownerWrite]))
10+
let permissions = try file.permissions()
11+
XCTAssertEqual(permissions, [.ownerRead, .ownerWrite])
12+
try file.delete()
13+
}
14+
15+
}

0 commit comments

Comments
 (0)