Skip to content

Tolerate mountinfo lines with an empty mount source#7044

Open
arpitjain099 wants to merge 2 commits into
spiffe:mainfrom
arpitjain099:fix/mountinfo-empty-source-7036
Open

Tolerate mountinfo lines with an empty mount source#7044
arpitjain099 wants to merge 2 commits into
spiffe:mainfrom
arpitjain099:fix/mountinfo-empty-source-7036

Conversation

@arpitjain099

Copy link
Copy Markdown

Pull Request check list

  • Commit conforms to CONTRIBUTING.md? (DCO sign-off included)
  • Proper tests/regressions included?
  • Documentation updated? (internal parser only, no user-facing docs affected)

Affected functionality

The docker and k8s workload attestors, which share pkg/common/containerinfo to read /proc/<pid>/mountinfo and recover the container ID.

Description of change

Implements the SPIRE-side approach confirmed on #7036. The attestor parses mountinfo via k8s.io/mount-utils ParseMountInfo, which splits each line with strings.Fields and requires at least 10 fields. Per proc(5) the mount source may be empty (a tmpfs mount has no source), rendering as an empty field between the filesystem type and the super options. The kernel escapes any real whitespace in a path as octal (\040), so the double space in a line like:

119 206 0:68 / /local rw,relatime - tmpfs  rw,size=8192k

unambiguously means an empty source. strings.Fields collapses that empty field, so the line counts as 9 fields and ParseMountInfo rejects the whole file with "wrong number of fields"; the attestor then extracts no container ID and no SVID is issued.

This replaces the upstream call with a small SPIRE-side mountinfo parser that is position-aware around the - separator, so the trailing fstype/source/super-options triple is read by position and an empty source is tolerated. Only the mount root and filesystem type are consumed by the extractor. Since this was the only user of k8s.io/mount-utils, the change also drops that dependency (and its transitive moby/sys/mountinfo).

A regression fixture with the empty-source tmpfs line and unit tests for the parser are included.

Which issue this PR fixes

fixes #7036

The docker workload attestor (and k8s, which shares the code) parses
/proc/<pid>/mountinfo via k8s.io/mount-utils ParseMountInfo, which splits
each line with strings.Fields and requires at least 10 fields. Per proc(5)
the mount source may be empty (a tmpfs mount has no source), which renders
as an empty field between the filesystem type and the super options. The
kernel escapes any real whitespace in a path as octal (\040), so the
double space in a line like:

    119 206 0:68 / /local rw,relatime - tmpfs  rw,size=8192k

unambiguously means an empty source. strings.Fields collapses that empty
field, so the line counts as 9 fields and ParseMountInfo rejects the whole
file with "wrong number of fields". The attestor then extracts no
container ID and no SVID is issued.

Replace the upstream call with a small SPIRE-side mountinfo parser that is
position-aware around the "-" separator, so the trailing fstype/source/
super-options triple is read by position and an empty source is tolerated.
Only the root and filesystem type are consumed by the extractor. This also
drops the k8s.io/mount-utils dependency (and its transitive moby/sys/
mountinfo) since it was the only user.

Adds a regression fixture with the empty-source tmpfs line plus unit tests
for the parser.

Signed-off-by: Arpit Jain <arpitjain099@gmail.com>
Copilot AI review requested due to automatic review settings June 5, 2026 20:50

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adds an internal /proc/<pid>/mountinfo parser to tolerate tmpfs mounts with an empty mount source field, preventing container ID extraction from failing due to upstream parsing behavior.

Changes:

  • Introduces parseMountInfo / parseMountInfoLine to parse mountinfo without rejecting valid tmpfs lines with an empty source.
  • Switches container ID extraction to use the new parser instead of k8s.io/mount-utils.
  • Adds regression tests and testdata covering the empty-source tmpfs case.

Reviewed changes

Copilot reviewed 6 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
pkg/common/containerinfo/mountinfo.go Implements a mountinfo parser tolerant of empty mount source fields.
pkg/common/containerinfo/mountinfo_test.go Adds unit tests for mountinfo line/file parsing, including the regression case.
pkg/common/containerinfo/extract.go Uses the new mountinfo parser during container ID extraction.
pkg/common/containerinfo/extract_test.go Adds an end-to-end regression test ensuring extraction succeeds with empty-source tmpfs.
pkg/common/containerinfo/testdata/docker/tmpfs-empty-source/proc/123/mountinfo Adds mountinfo fixture containing an empty-source tmpfs mount.
go.mod / go.sum Removes now-unneeded dependencies (k8s.io/mount-utils, github.com/moby/sys/mountinfo).

Comment thread pkg/common/containerinfo/mountinfo.go Outdated
Comment on lines +88 to +96
// After the separator there are exactly three positional fields:
// filesystem type, mount source, and super options. The source may be
// empty, so parse by splitting into at most three fields and keeping the
// first (filesystem type). Trailing whitespace from an empty source does
// not affect the filesystem type, which is the first non-empty token.
after := strings.Fields(line[sep+len(" - "):])
if len(after) < 1 {
return mountInfo{}, fmt.Errorf("missing filesystem type in mountinfo line: %s", line)
}
Comment thread pkg/common/containerinfo/mountinfo.go Outdated
Comment on lines +39 to +54
content, err := os.ReadFile(filename)
if err != nil {
return nil, err
}

var infos []mountInfo
for _, line := range strings.Split(string(content), "\n") {
if line == "" {
continue
}
info, err := parseMountInfoLine(line)
if err != nil {
return nil, err
}
infos = append(infos, info)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems useful to do and could simplify things a bit.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in the same commit (4af7f2f). Swapped ReadFile + Split for bufio.Scanner, so it streams line by line now.

@cbartleroblox

Copy link
Copy Markdown

Thank you!

@sorindumitru sorindumitru added this to the 1.15.2 milestone Jun 10, 2026
@sorindumitru

Copy link
Copy Markdown
Member

Thanks for opening this PR @arpitjain099

Per proc(5) the mount source may be empty (a tmpfs mount has no source), rendering as an empty field between the filesystem type and the super options.

Could you share a source for this? My man 5 proc_pid_mountinfo says the following:

mount source: filesystem-specific information or "none".

Could you share some details about the system where you encountered this or a way to reproduce (e.g. can I just start a docker container with some options)?

@arpitjain099

Copy link
Copy Markdown
Author

Thanks @sorindumitru! This is specifically the tmpfs case. You're right that proc(5) documents the source as "filesystem-specific information or 'none'", but tmpfs mounts in practice have no source at all - Docker's own docs state "There is no source for tmpfs mounts", and the kernel emits a literal empty field rather than none.

The concrete line is in #7036 (filed by @amartinezfayo):

119 206 0:68 / /local rw,relatime - tmpfs  rw,size=8192k

Note the double space between tmpfs and rw,size=8192k - that is the empty source field (fstype tmpfs, empty source, super-options rw,size=8192k). Because the kernel octal-escapes real whitespace in paths as \040, a literal double space unambiguously means an empty source rather than a space in a name. strings.Fields in the upstream k8s.io/mount-utils parser collapses that empty field, so the line reads as 9 fields and the whole file is rejected - which is why a position-aware parse of the trailing fstype/source/super-options triple is needed.

To reproduce with Docker:

docker run --rm --tmpfs /local:rw,size=8192k alpine sh -c 'grep " tmpfs " /proc/self/mountinfo'

You should see the tmpfs line with the empty source. A Kubernetes pod with an emptyDir: {medium: Memory} volume produces the same kind of mount.

@sorindumitru

Copy link
Copy Markdown
Member

@arpitjain099 this is what I get from that:

735 726 0:131 / /local rw,nosuid,nodev,noexec,relatime - tmpfs tmpfs rw,size=8192k,inode64,huge=within_size

This is with a recent kernel version, though 7.0. Are you running this on linux, and which distro/kernel version, or some other operating system?

@sorindumitru

Copy link
Copy Markdown
Member

@arpitjain099 this is what I get from that:

735 726 0:131 / /local rw,nosuid,nodev,noexec,relatime - tmpfs tmpfs rw,size=8192k,inode64,huge=within_size

This is with a recent kernel version, though 7.0. Are you running this on linux, and which distro/kernel version, or some other operating system?

Was able to reproduce by calling mount syscall with an empty source.

@arpitjain099

Copy link
Copy Markdown
Author

Thanks for digging in @sorindumitru, and good that the mount syscall with an empty source reproduces it. That is the validation that matters here: an empty source is valid mountinfo the parser has to accept.

To be upfront about the environment question: I didn't hit this on my own machine. The concrete line came from @amartinezfayo's report in #7036 (a real workload, /proc/943/mountinfo), and my part was diagnosing the root cause - strings.Fields collapsing the empty source field so the line reads as 9 columns and k8s.io/mount-utils rejects the whole file - plus the position-aware fix. @amartinezfayo would have the exact distro/kernel for the original.

As you found, the trigger is the mount source being literally empty (a mount call with an empty source, as some runtimes do for in-memory mounts) rather than the kernel writing tmpfs or none. Recent kernels populating tmpfs for docker --tmpfs just means that one path doesn't hit it on 7.0; the empty-source form is still valid and still reaches this parser, which is what the fix handles. Happy to add a test fixture using the exact 9-column line from #7036 if that helps lock it in.

@cbartleroblox

Copy link
Copy Markdown

Validated that this change doesn't trigger the parser issue.

Comment thread pkg/common/containerinfo/mountinfo.go Outdated
Comment on lines +39 to +54
content, err := os.ReadFile(filename)
if err != nil {
return nil, err
}

var infos []mountInfo
for _, line := range strings.Split(string(content), "\n") {
if line == "" {
continue
}
info, err := parseMountInfoLine(line)
if err != nil {
return nil, err
}
infos = append(infos, info)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems useful to do and could simplify things a bit.

Comment thread pkg/common/containerinfo/mountinfo.go Outdated
// spaces. Splitting on " - " keeps it unambiguous: optional-field tags
// before the separator never equal a bare "-", and the post-separator
// fields are matched by position below.
sep := strings.Index(line, " - ")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at what strings offers, I think strings.Split(line, " ") should do what we want here and maybe it would simplify things a bit. What do you think?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Switched to strings.Split(line, " ") so empty fields survive as empty strings in the slice. This lets us locate the separator and all post-separator fields by index directly, no more separate Index + Fields split. Pushed in 4af7f2f.

Switch parseMountInfoLine from the Index+Fields approach to
strings.Split(line, " "), which preserves empty fields between
consecutive spaces. This makes the empty-source handling more
direct: the separator and all fields are located by index in
a single slice.

Also switch parseMountInfo to stream lines with bufio.Scanner
instead of reading the whole file into memory first.

Signed-off-by: Arpit Jain <arpitjain099@gmail.com>
Comment on lines +83 to +89
sepIdx := -1
for i, f := range fields {
if f == "-" {
sepIdx = i
break
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should work here, but might need an extra import:

Suggested change
sepIdx := -1
for i, f := range fields {
if f == "-" {
sepIdx = i
break
}
}
sepIdx := slices.Index(fields, "-")

@sorindumitru

Copy link
Copy Markdown
Member

Hi @arpitjain099, do you think you'll have time to address that small comment I left and resolve the conflicts? If you won't I can try to push the changes over the branch, but we'd want to include this in the next release.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

docker/k8s workload attestor fails on tmpfs mount with empty source

4 participants